Merge branch 'develop' into fix-13321-2

This commit is contained in:
anatawa12
2024-03-10 23:39:52 +09:00
245 changed files with 6969 additions and 4410 deletions

View File

@@ -401,7 +401,8 @@ function toStories(component: string): Promise<string> {
// glob('src/{components,pages,ui,widgets}/**/*.vue')
(async () => {
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/MkDigitalClock.vue'),
glob('src/components/MkGalleryPostPreview.vue'),

View File

@@ -27,19 +27,19 @@
"@syuilo/aiscript": "0.17.0",
"@tabler/icons-webfont": "2.44.0",
"@twemoji/parser": "15.0.0",
"@vitejs/plugin-vue": "5.0.3",
"@vue/compiler-sfc": "3.4.18",
"@vitejs/plugin-vue": "5.0.4",
"@vue/compiler-sfc": "3.4.21",
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.2",
"astring": "1.8.6",
"broadcast-channel": "7.0.0",
"buraha": "0.0.1",
"canvas-confetti": "1.6.1",
"chart.js": "4.4.1",
"canvas-confetti": "1.9.2",
"chart.js": "4.4.2",
"chartjs-adapter-date-fns": "3.0.0",
"chartjs-chart-matrix": "2.0.1",
"chartjs-plugin-gradient": "0.6.1",
"chartjs-plugin-zoom": "2.0.1",
"chromatic": "10.6.1",
"chromatic": "11.0.0",
"compare-versions": "6.1.0",
"cropperjs": "2.0.0-beta.4",
"date-fns": "2.30.0",
@@ -57,79 +57,79 @@
"misskey-reversi": "workspace:*",
"photoswipe": "5.4.3",
"punycode": "2.3.1",
"rollup": "4.9.6",
"sanitize-html": "2.11.0",
"sass": "1.70.0",
"shiki": "1.0.0-beta.3",
"rollup": "4.12.0",
"sanitize-html": "2.12.1",
"sass": "1.71.1",
"shiki": "1.1.7",
"strict-event-emitter-types": "2.0.0",
"textarea-caret": "3.1.0",
"three": "0.160.1",
"three": "0.162.0",
"throttle-debounce": "5.0.0",
"tinycolor2": "1.6.0",
"tsc-alias": "1.8.8",
"tsconfig-paths": "4.2.0",
"typescript": "5.3.3",
"uuid": "9.0.1",
"v-code-diff": "1.7.2",
"vite": "5.1.0",
"vue": "3.4.18",
"v-code-diff": "1.9.0",
"vite": "5.1.4",
"vue": "3.4.21",
"vuedraggable": "next"
},
"devDependencies": {
"@misskey-dev/eslint-plugin": "1.0.0",
"@misskey-dev/summaly": "5.0.3",
"@storybook/addon-actions": "8.0.0-beta.2",
"@storybook/addon-essentials": "8.0.0-beta.2",
"@storybook/addon-interactions": "8.0.0-beta.2",
"@storybook/addon-links": "8.0.0-beta.2",
"@storybook/addon-mdx-gfm": "8.0.0-beta.2",
"@storybook/addon-storysource": "8.0.0-beta.2",
"@storybook/blocks": "8.0.0-beta.2",
"@storybook/components": "8.0.0-beta.2",
"@storybook/core-events": "8.0.0-beta.2",
"@storybook/manager-api": "8.0.0-beta.2",
"@storybook/preview-api": "8.0.0-beta.2",
"@storybook/react": "8.0.0-beta.2",
"@storybook/react-vite": "8.0.0-beta.2",
"@storybook/test": "8.0.0-beta.2",
"@storybook/theming": "8.0.0-beta.2",
"@storybook/types": "8.0.0-beta.2",
"@storybook/vue3": "8.0.0-beta.2",
"@storybook/vue3-vite": "8.0.0-beta.2",
"@storybook/addon-actions": "8.0.0-beta.6",
"@storybook/addon-essentials": "8.0.0-beta.6",
"@storybook/addon-interactions": "8.0.0-beta.6",
"@storybook/addon-links": "8.0.0-beta.6",
"@storybook/addon-mdx-gfm": "8.0.0-beta.6",
"@storybook/addon-storysource": "8.0.0-beta.6",
"@storybook/blocks": "8.0.0-beta.6",
"@storybook/components": "8.0.0-beta.6",
"@storybook/core-events": "8.0.0-beta.6",
"@storybook/manager-api": "8.0.0-beta.6",
"@storybook/preview-api": "8.0.0-beta.6",
"@storybook/react": "8.0.0-beta.6",
"@storybook/react-vite": "8.0.0-beta.6",
"@storybook/test": "8.0.0-beta.6",
"@storybook/theming": "8.0.0-beta.6",
"@storybook/types": "8.0.0-beta.6",
"@storybook/vue3": "8.0.0-beta.6",
"@storybook/vue3-vite": "8.0.0-beta.6",
"@testing-library/vue": "8.0.2",
"@types/escape-regexp": "0.0.3",
"@types/estree": "1.0.5",
"@types/matter-js": "0.19.6",
"@types/micromatch": "4.0.6",
"@types/node": "20.11.17",
"@types/punycode": "2.1.3",
"@types/sanitize-html": "2.9.5",
"@types/node": "20.11.22",
"@types/punycode": "2.1.4",
"@types/sanitize-html": "2.11.0",
"@types/throttle-debounce": "5.0.2",
"@types/tinycolor2": "1.4.6",
"@types/uuid": "9.0.8",
"@types/ws": "8.5.10",
"@typescript-eslint/eslint-plugin": "6.18.1",
"@typescript-eslint/parser": "6.18.1",
"@typescript-eslint/eslint-plugin": "7.1.0",
"@typescript-eslint/parser": "7.1.0",
"@vitest/coverage-v8": "0.34.6",
"@vue/runtime-core": "3.4.18",
"@vue/runtime-core": "3.4.21",
"acorn": "8.11.3",
"cross-env": "7.0.3",
"cypress": "13.6.4",
"eslint": "8.56.0",
"cypress": "13.6.6",
"eslint": "8.57.0",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-vue": "9.20.1",
"eslint-plugin-vue": "9.22.0",
"fast-glob": "3.3.2",
"happy-dom": "10.0.3",
"happy-dom": "13.6.2",
"intersection-observer": "0.12.2",
"micromatch": "4.0.5",
"msw": "2.1.7",
"msw-storybook-addon": "2.0.0-beta.1",
"nodemon": "3.0.3",
"nodemon": "3.1.0",
"prettier": "3.2.5",
"react": "18.2.0",
"react-dom": "18.2.0",
"start-server-and-test": "2.0.3",
"storybook": "8.0.0-beta.2",
"storybook": "8.0.0-beta.6",
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
"vite-plugin-turbosnap": "1.0.3",
"vitest": "0.34.6",

View File

@@ -290,7 +290,7 @@ export async function openAccountMenu(opts: {
text: i18n.ts.profile,
to: `/@${ $i.username }`,
avatar: $i,
}, { type: 'divider' }, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, {
}, { type: 'divider' as const }, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, {
type: 'parent' as const,
icon: 'ti ti-plus',
text: i18n.ts.addAccount,

View File

@@ -11,7 +11,7 @@ import { alert, confirm, popup, post, toast } from '@/os.js';
import { useStream } from '@/stream.js';
import * as sound from '@/scripts/sound.js';
import { $i, signout, updateAccount } from '@/account.js';
import { fetchInstance, instance } from '@/instance.js';
import { instance } from '@/instance.js';
import { ColdDeviceStorage, defaultStore } from '@/store.js';
import { makeHotkey } from '@/scripts/hotkey.js';
import { reactionPicker } from '@/scripts/reaction-picker.js';
@@ -187,14 +187,26 @@ export async function mainBoot() {
if ($i.followersCount >= 500) claimAchievement('followers500');
if ($i.followersCount >= 1000) claimAchievement('followers1000');
if (Date.now() - new Date($i.createdAt).getTime() > 1000 * 60 * 60 * 24 * 365) {
claimAchievement('passedSinceAccountCreated1');
}
if (Date.now() - new Date($i.createdAt).getTime() > 1000 * 60 * 60 * 24 * 365 * 2) {
claimAchievement('passedSinceAccountCreated2');
}
if (Date.now() - new Date($i.createdAt).getTime() > 1000 * 60 * 60 * 24 * 365 * 3) {
const createdAt = new Date($i.createdAt);
const createdAtThreeYearsLater = new Date($i.createdAt);
createdAtThreeYearsLater.setFullYear(createdAtThreeYearsLater.getFullYear() + 3);
if (now >= createdAtThreeYearsLater) {
claimAchievement('passedSinceAccountCreated3');
claimAchievement('passedSinceAccountCreated2');
claimAchievement('passedSinceAccountCreated1');
} else {
const createdAtTwoYearsLater = new Date($i.createdAt);
createdAtTwoYearsLater.setFullYear(createdAtTwoYearsLater.getFullYear() + 2);
if (now >= createdAtTwoYearsLater) {
claimAchievement('passedSinceAccountCreated2');
claimAchievement('passedSinceAccountCreated1');
} else {
const createdAtOneYearLater = new Date($i.createdAt);
createdAtOneYearLater.setFullYear(createdAtOneYearLater.getFullYear() + 1);
if (now >= createdAtOneYearLater) {
claimAchievement('passedSinceAccountCreated1');
}
}
}
if (claimedAchievements.length >= 30) {
@@ -229,18 +241,16 @@ export async function mainBoot() {
const latestDonationInfoShownAt = miLocalStorage.getItem('latestDonationInfoShownAt');
const neverShowDonationInfo = miLocalStorage.getItem('neverShowDonationInfo');
if (neverShowDonationInfo !== 'true' && (new Date($i.createdAt).getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 3))) && !location.pathname.startsWith('/miauth')) {
if (neverShowDonationInfo !== 'true' && (createdAt.getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 3))) && !location.pathname.startsWith('/miauth')) {
if (latestDonationInfoShownAt == null || (new Date(latestDonationInfoShownAt).getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 30)))) {
popup(defineAsyncComponent(() => import('@/components/MkDonation.vue')), {}, {}, 'closed');
}
}
fetchInstance().then(() => {
const modifiedVersionMustProminentlyOfferInAgplV3Section13Read = miLocalStorage.getItem('modifiedVersionMustProminentlyOfferInAgplV3Section13Read');
if (modifiedVersionMustProminentlyOfferInAgplV3Section13Read !== 'true' && instance.repositoryUrl !== 'https://github.com/misskey-dev/misskey') {
popup(defineAsyncComponent(() => import('@/components/MkSourceCodeAvailablePopup.vue')), {}, {}, 'closed');
}
});
const modifiedVersionMustProminentlyOfferInAgplV3Section13Read = miLocalStorage.getItem('modifiedVersionMustProminentlyOfferInAgplV3Section13Read');
if (modifiedVersionMustProminentlyOfferInAgplV3Section13Read !== 'true' && instance.repositoryUrl !== 'https://github.com/misskey-dev/misskey') {
popup(defineAsyncComponent(() => import('@/components/MkSourceCodeAvailablePopup.vue')), {}, {}, 'closed');
}
if ('Notification' in window) {
// 許可を得ていなかったらリクエスト

View File

@@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</ol>
<ol v-else-if="emojis.length > 0" ref="suggests" :class="$style.list">
<li v-for="emoji in emojis" :key="emoji.emoji" :class="$style.item" tabindex="-1" @click="complete(type, emoji.emoji)" @keydown="onKeydown">
<MkCustomEmoji v-if="'isCustomEmoji' in emoji && emoji.isCustomEmoji" :name="emoji.emoji" :class="$style.emoji"/>
<MkCustomEmoji v-if="'isCustomEmoji' in emoji && emoji.isCustomEmoji" :name="emoji.emoji" :class="$style.emoji" :fallbackToImage="true"/>
<MkEmoji v-else :emoji="emoji.emoji" :class="$style.emoji"/>
<!-- eslint-disable-next-line vue/no-v-html -->
<span v-if="q" :class="$style.emojiName" v-html="sanitizeHtml(emoji.name.replace(q, `<b>${q}</b>`))"></span>
@@ -57,18 +57,7 @@ import { i18n } from '@/i18n.js';
import { miLocalStorage } from '@/local-storage.js';
import { customEmojis } from '@/custom-emojis.js';
import { MFM_TAGS, MFM_PARAMS } from '@/const.js';
type EmojiDef = {
emoji: string;
name: string;
url: string;
aliasOf?: string;
} | {
emoji: string;
name: string;
aliasOf?: string;
isCustomEmoji?: true;
};
import { searchEmoji, EmojiDef } from '@/scripts/search-emoji.js';
const lib = emojilist.filter(x => x.category !== 'flags');
@@ -88,7 +77,7 @@ const emojiDb = computed(() => {
unicodeEmojiDB.push({
emoji: emoji,
name: k,
aliasOf: getEmojiName(emoji)!,
aliasOf: getEmojiName(emoji),
url: char2path(emoji),
});
}
@@ -249,7 +238,7 @@ function exec() {
return;
}
emojis.value = emojiAutoComplete(props.q, emojiDb.value);
emojis.value = searchEmoji(props.q, emojiDb.value);
} else if (props.type === 'mfmTag') {
if (!props.q || props.q === '') {
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 });
}
}
// ヒットしたものを全部追加すると雑多になるので、先頭の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);
}
function onMousedown(event: Event) {
if (!contains(rootEl.value, event.target) && (rootEl.value !== event.target)) props.close();
}

View File

@@ -240,7 +240,7 @@ const render = () => {
},
external: externalTooltipHandler,
callbacks: {
label: (item) => chartData?.bytes ? bytes(item.parsed.y * 1000, 1) : item.parsed.y.toString(),
label: (item) => `${item.dataset.label}: ${chartData?.bytes ? bytes(item.parsed.y * 1000, 1) : item.parsed.y.toString()}`,
},
},
zoom: props.detailed ? {

View File

@@ -52,7 +52,7 @@ async function fetchLanguage(to: string): Promise<void> {
return bundle.id === language || bundle.aliases?.includes(language);
});
if (bundles.length > 0) {
console.log(`Loading language: ${language}`);
if (_DEV_) console.log(`Loading language: ${language}`);
await highlighter.loadLanguage(bundles[0].import);
codeLang.value = language;
} else {

View File

@@ -38,11 +38,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<template v-if="select.items">
<option v-for="item in select.items" :value="item.value">{{ item.text }}</option>
</template>
<template v-else>
<optgroup v-for="groupedItem in select.groupedItems" :label="groupedItem.label">
<option v-for="item in groupedItem.items" :value="item.value">{{ item.text }}</option>
</optgroup>
</template>
</MkSelect>
<div v-if="(showOkButton || showCancelButton) && !actions" :class="$style.buttons">
<MkButton v-if="showOkButton" data-cy-modal-dialog-ok inline primary rounded :autofocus="!input && !select" :disabled="okButtonDisabledReason" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton>
@@ -64,7 +59,7 @@ import MkSelect from '@/components/MkSelect.vue';
import { i18n } from '@/i18n.js';
type Input = {
type: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search' | 'datetime-local';
type?: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search' | 'datetime-local';
placeholder?: string | null;
autocomplete?: string;
default: string | number | null;
@@ -74,22 +69,17 @@ type Input = {
type Select = {
items: {
value: string;
value: any;
text: string;
}[];
groupedItems: {
label: string;
items: {
value: string;
text: string;
}[];
}[];
default: string | null;
};
type Result = string | number | true | null;
const props = withDefaults(defineProps<{
type?: 'success' | 'error' | 'warning' | 'info' | 'question' | 'waiting';
title: string;
title?: string;
text?: string;
input?: Input;
select?: Select;
@@ -113,7 +103,7 @@ const props = withDefaults(defineProps<{
});
const emit = defineEmits<{
(ev: 'done', v: { canceled: boolean; result: any }): void;
(ev: 'done', v: { canceled: true } | { canceled: false, result: Result }): void;
(ev: 'closed'): void;
}>();
@@ -139,8 +129,11 @@ const okButtonDisabledReason = computed<null | 'charactersExceeded' | 'character
return null;
});
function done(canceled: boolean, result?) {
emit('done', { canceled, result });
// overload function を使いたいので lint エラーを無視する
function done(canceled: true): void;
function done(canceled: false, result: Result): void; // eslint-disable-line no-redeclare
function done(canceled: boolean, result?: Result): void { // eslint-disable-line no-redeclare
emit('done', { canceled, result } as { canceled: true } | { canceled: false, result: Result });
modal.value?.close();
}

View File

@@ -39,13 +39,13 @@ withDefaults(defineProps<{
});
const emit = defineEmits<{
(ev: 'done', r?: Misskey.entities.DriveFile[]): void;
(ev: 'done', r?: Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]): void;
(ev: 'closed'): void;
}>();
const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
const selected = ref<Misskey.entities.DriveFile[]>([]);
const selected = ref<Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]>([]);
function ok() {
emit('done', selected.value);
@@ -57,7 +57,7 @@ function cancel() {
dialog.value?.close();
}
function onChangeSelection(files: Misskey.entities.DriveFile[]) {
selected.value = files;
function onChangeSelection(v: Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]) {
selected.value = v;
}
</script>

View File

@@ -16,10 +16,11 @@ SPDX-License-Identifier: AGPL-3.0-only
:key="emoji"
:data-emoji="emoji"
class="_button item"
:disabled="disabledEmojis?.value.includes(emoji)"
@pointerenter="computeButtonTitle"
@click="emit('chosen', emoji, $event)"
>
<MkCustomEmoji v-if="emoji[0] === ':'" class="emoji" :name="emoji" :normal="true"/>
<MkCustomEmoji v-if="emoji[0] === ':'" class="emoji" :name="emoji" :normal="true" :fallbackToImage="true"/>
<MkEmoji v-else class="emoji" :emoji="emoji" :normal="true"/>
</button>
</div>
@@ -48,6 +49,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:key="emoji"
:data-emoji="emoji"
class="_button item"
:disabled="disabledEmojis?.value.includes(emoji)"
@pointerenter="computeButtonTitle"
@click="emit('chosen', emoji, $event)"
>
@@ -67,6 +69,7 @@ import MkEmojiPickerSection from '@/components/MkEmojiPicker.section.vue';
const props = defineProps<{
emojis: string[] | Ref<string[]>;
disabledEmojis?: Ref<string[]>;
initialShown?: boolean;
hasChildSection?: boolean;
customEmojiTree?: CustomEmojiFolderTree[];
@@ -84,7 +87,7 @@ const shown = ref(!!props.initialShown);
function computeButtonTitle(ev: MouseEvent): void {
const elm = ev.target as HTMLElement;
const emoji = elm.dataset.emoji as string;
elm.title = getEmojiName(emoji) ?? emoji;
elm.title = getEmojiName(emoji);
}
function nestedChosen(emoji: any, ev: MouseEvent) {

View File

@@ -14,11 +14,12 @@ SPDX-License-Identifier: AGPL-3.0-only
v-for="emoji in searchResultCustom"
:key="emoji.name"
class="_button item"
:disabled="!canReact(emoji)"
:title="emoji.name"
tabindex="0"
@click="chosen(emoji, $event)"
>
<MkCustomEmoji class="emoji" :name="emoji.name"/>
<MkCustomEmoji class="emoji" :name="emoji.name" :fallbackToImage="true"/>
</button>
</div>
<div v-if="searchResultUnicode.length > 0" class="body">
@@ -39,16 +40,17 @@ SPDX-License-Identifier: AGPL-3.0-only
<section v-if="showPinned && (pinned && pinned.length > 0)">
<div class="body">
<button
v-for="emoji in pinned"
:key="emoji"
:data-emoji="emoji"
v-for="emoji in pinnedEmojisDef"
:key="getKey(emoji)"
:data-emoji="getKey(emoji)"
class="_button item"
:disabled="!canReact(emoji)"
tabindex="0"
@pointerenter="computeButtonTitle"
@click="chosen(emoji, $event)"
>
<MkCustomEmoji v-if="emoji[0] === ':'" class="emoji" :name="emoji" :normal="true"/>
<MkEmoji v-else class="emoji" :emoji="emoji" :normal="true"/>
<MkCustomEmoji v-if="!emoji.hasOwnProperty('char')" class="emoji" :name="getKey(emoji)" :normal="true"/>
<MkEmoji v-else class="emoji" :emoji="getKey(emoji)" :normal="true"/>
</button>
</div>
</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>
<div class="body">
<button
v-for="emoji in recentlyUsedEmojis"
:key="emoji"
v-for="emoji in recentlyUsedEmojisDef"
:key="getKey(emoji)"
class="_button item"
:data-emoji="emoji"
:disabled="!canReact(emoji)"
:data-emoji="getKey(emoji)"
@pointerenter="computeButtonTitle"
@click="chosen(emoji, $event)"
>
<MkCustomEmoji v-if="emoji[0] === ':'" class="emoji" :name="emoji" :normal="true"/>
<MkEmoji v-else class="emoji" :emoji="emoji" :normal="true"/>
<MkCustomEmoji v-if="!emoji.hasOwnProperty('char')" class="emoji" :name="getKey(emoji)" :normal="true"/>
<MkEmoji v-else class="emoji" :emoji="getKey(emoji)" :normal="true"/>
</button>
</div>
</section>
@@ -76,7 +79,8 @@ SPDX-License-Identifier: AGPL-3.0-only
v-for="child in customEmojiFolderRoot.children"
:key="`custom:${child.value}`"
: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"
:customEmojiTree="child.children"
@chosen="chosen"
@@ -109,6 +113,7 @@ import {
unicodeEmojiCategories as categories,
getEmojiName,
CustomEmojiFolderTree,
getUnicodeEmoji,
} from '@/scripts/emojilist.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import * as os from '@/os.js';
@@ -146,6 +151,13 @@ const {
recentlyUsedEmojis,
} = defaultStore.reactiveState;
const recentlyUsedEmojisDef = computed(() => {
return recentlyUsedEmojis.value.map(getDef);
});
const pinnedEmojisDef = computed(() => {
return pinned.value?.map(getDef);
});
const pinned = computed(() => props.pinnedEmojis);
const size = computed(() => emojiPickerScale.value);
const width = computed(() => emojiPickerWidth.value);
@@ -337,14 +349,18 @@ watch(q, () => {
return matches;
};
searchResultCustom.value = Array.from(searchCustom()).filter(filterAvailable);
searchResultCustom.value = Array.from(searchCustom());
searchResultUnicode.value = Array.from(searchUnicode());
});
function filterAvailable(emoji: Misskey.entities.EmojiSimple): boolean {
function canReact(emoji: Misskey.entities.EmojiSimple | UnicodeEmojiDef | string): boolean {
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() {
if (!['smartphone', 'tablet'].includes(deviceKind) && !isTouchUsing) {
searchEl.value?.focus({
@@ -362,11 +378,22 @@ function getKey(emoji: string | Misskey.entities.EmojiSimple | UnicodeEmojiDef):
return typeof emoji === 'string' ? emoji : 'char' in emoji ? emoji.char : `:${emoji.name}:`;
}
function getDef(emoji: string): string | Misskey.entities.EmojiSimple | UnicodeEmojiDef {
if (emoji.includes(':')) {
// カスタム絵文字が存在する場合はその情報を持つオブジェクトを返し、
// サーバの管理画面から削除された等で情報が見つからない場合は名前の文字列をそのまま返しておくundefinedを返すとエラーになるため
const name = emoji.replaceAll(':', '');
return customEmojisMap.get(name) ?? emoji;
} else {
return getUnicodeEmoji(emoji);
}
}
/** @see MkEmojiPicker.section.vue */
function computeButtonTitle(ev: MouseEvent): void {
const elm = ev.target as HTMLElement;
const emoji = elm.dataset.emoji as string;
elm.title = getEmojiName(emoji) ?? emoji;
elm.title = getEmojiName(emoji);
}
function chosen(emoji: any, ev?: MouseEvent) {
@@ -526,6 +553,18 @@ defineExpose({
width: auto;
height: auto;
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 +587,18 @@ defineExpose({
width: auto;
height: auto;
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 +714,18 @@ defineExpose({
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 {
height: 1.25em;
vertical-align: -.25em;

View File

@@ -56,7 +56,7 @@ const props = withDefaults(defineProps<{
});
const emit = defineEmits<{
(ev: 'done', v: any): void;
(ev: 'done', v: string): void;
(ev: 'close'): void;
(ev: 'closed'): void;
}>();
@@ -64,7 +64,7 @@ const emit = defineEmits<{
const modal = shallowRef<InstanceType<typeof MkModal>>();
const picker = shallowRef<InstanceType<typeof MkEmojiPicker>>();
function chosen(emoji: any) {
function chosen(emoji: string) {
emit('done', emoji);
if (props.choseAndClose) {
modal.value?.close();

View File

@@ -1,49 +0,0 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkWindow
ref="window"
:initialWidth="300"
:initialHeight="290"
:canResize="true"
:mini="true"
:front="true"
@closed="emit('closed')"
>
<MkEmojiPicker :showPinned="showPinned" :asReactionPicker="asReactionPicker" :targetNote="targetNote" asWindow :class="$style.picker" @chosen="chosen"/>
</MkWindow>
</template>
<script lang="ts" setup>
import { } from 'vue';
import * as Misskey from 'misskey-js';
import MkWindow from '@/components/MkWindow.vue';
import MkEmojiPicker from '@/components/MkEmojiPicker.vue';
withDefaults(defineProps<{
src?: HTMLElement;
showPinned?: boolean;
asReactionPicker?: boolean;
targetNote?: Misskey.entities.Note
}>(), {
showPinned: true,
});
const emit = defineEmits<{
(ev: 'chosen', v: any): void;
(ev: 'closed'): void;
}>();
function chosen(emoji: any) {
emit('chosen', emoji);
}
</script>
<style lang="scss" module>
.picker {
height: 100%;
}
</style>

View File

@@ -21,37 +21,37 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSpacer :marginMin="20" :marginMax="32">
<div v-if="Object.keys(form).filter(item => !form[item].hidden).length > 0" class="_gaps_m">
<template v-for="item in Object.keys(form).filter(item => !form[item].hidden)">
<MkInput v-if="form[item].type === 'number'" v-model="values[item]" type="number" :step="form[item].step || 1">
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="form[item].description" #caption>{{ form[item].description }}</template>
<template v-for="(v, k) in Object.fromEntries(Object.entries(form).filter(([_, v]) => !('hidden' in v) || 'hidden' in v && !v.hidden))">
<MkInput v-if="v.type === 'number'" v-model="values[k]" type="number" :step="v.step || 1">
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="v.description" #caption>{{ v.description }}</template>
</MkInput>
<MkInput v-else-if="form[item].type === 'string' && !form[item].multiline" v-model="values[item]" type="text" :mfmAutocomplete="form[item].treatAsMfm">
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="form[item].description" #caption>{{ form[item].description }}</template>
<MkInput v-else-if="v.type === 'string' && !v.multiline" v-model="values[k]" type="text" :mfmAutocomplete="v.treatAsMfm">
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="v.description" #caption>{{ v.description }}</template>
</MkInput>
<MkTextarea v-else-if="form[item].type === 'string' && form[item].multiline" v-model="values[item]" :mfmAutocomplete="form[item].treatAsMfm" :mfmPreview="form[item].treatAsMfm">
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="form[item].description" #caption>{{ form[item].description }}</template>
<MkTextarea v-else-if="v.type === 'string' && v.multiline" v-model="values[k]" :mfmAutocomplete="v.treatAsMfm" :mfmPreview="v.treatAsMfm">
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="v.description" #caption>{{ v.description }}</template>
</MkTextarea>
<MkSwitch v-else-if="form[item].type === 'boolean'" v-model="values[item]">
<span v-text="form[item].label || item"></span>
<template v-if="form[item].description" #caption>{{ form[item].description }}</template>
<MkSwitch v-else-if="v.type === 'boolean'" v-model="values[k]">
<span v-text="v.label || k"></span>
<template v-if="v.description" #caption>{{ v.description }}</template>
</MkSwitch>
<MkSelect v-else-if="form[item].type === 'enum'" v-model="values[item]">
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template>
<option v-for="option in form[item].enum" :key="option.value" :value="option.value">{{ option.label }}</option>
<MkSelect v-else-if="v.type === 'enum'" v-model="values[k]">
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<option v-for="option in v.enum" :key="option.value" :value="option.value">{{ option.label }}</option>
</MkSelect>
<MkRadios v-else-if="form[item].type === 'radio'" v-model="values[item]">
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template>
<option v-for="option in form[item].options" :key="option.value" :value="option.value">{{ option.label }}</option>
<MkRadios v-else-if="v.type === 'radio'" v-model="values[k]">
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<option v-for="option in v.options" :key="option.value" :value="option.value">{{ option.label }}</option>
</MkRadios>
<MkRange v-else-if="form[item].type === 'range'" v-model="values[item]" :min="form[item].min" :max="form[item].max" :step="form[item].step" :textConverter="form[item].textConverter">
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="form[item].description" #caption>{{ form[item].description }}</template>
<MkRange v-else-if="v.type === 'range'" v-model="values[k]" :min="v.min" :max="v.max" :step="v.step" :textConverter="v.textConverter">
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="v.description" #caption>{{ v.description }}</template>
</MkRange>
<MkButton v-else-if="form[item].type === 'button'" @click="form[item].action($event, values)">
<span v-text="form[item].content || item"></span>
<MkButton v-else-if="v.type === 'button'" @click="v.action($event, values)">
<span v-text="v.content || k"></span>
</MkButton>
</template>
</div>
@@ -72,19 +72,21 @@ import MkSelect from './MkSelect.vue';
import MkRange from './MkRange.vue';
import MkButton from './MkButton.vue';
import MkRadios from './MkRadios.vue';
import type { Form } from '@/scripts/form.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import { i18n } from '@/i18n.js';
import { infoImageUrl } from '@/instance.js';
const props = defineProps<{
title: string;
form: any;
form: Form;
}>();
const emit = defineEmits<{
(ev: 'done', v: {
canceled?: boolean;
result?: any;
canceled: true;
} | {
result: Record<string, any>;
}): void;
(ev: 'closed'): void;
}>();

View File

@@ -66,7 +66,7 @@ import * as os from '@/os.js';
import bytes from '@/filters/bytes.js';
import { hms } from '@/filters/hms.js';
import MkMediaRange from '@/components/MkMediaRange.vue';
import { iAmModerator } from '@/account.js';
import { $i, iAmModerator } from '@/account.js';
const props = defineProps<{
audio: Misskey.entities.DriveFile;
@@ -96,8 +96,6 @@ function showMenu(ev: MouseEvent) {
if (iAmModerator) {
menu.push({
type: 'divider',
}, {
text: props.audio.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive,
icon: props.audio.isSensitive ? 'ti ti-eye' : 'ti ti-eye-exclamation',
danger: true,
@@ -105,6 +103,17 @@ function showMenu(ev: MouseEvent) {
});
}
if ($i?.id === props.audio.userId) {
menu.push({
type: 'divider',
}, {
type: 'link' as const,
text: i18n.ts._fileViewer.title,
icon: 'ti ti-info-circle',
to: `/my/drive/file/${props.audio.id}`,
});
}
menuShowing.value = true;
os.popupMenu(menu, ev.currentTarget ?? ev.target, {
align: 'right',

View File

@@ -59,7 +59,7 @@ import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { iAmModerator } from '@/account.js';
import { $i, iAmModerator } from '@/account.js';
const props = withDefaults(defineProps<{
image: Misskey.entities.DriveFile;
@@ -114,6 +114,13 @@ function showMenu(ev: MouseEvent) {
action: () => {
os.apiWithDialog('drive/files/update', { fileId: props.image.id, isSensitive: true });
},
}] : []), ...($i?.id === props.image.userId ? [{
type: 'divider' as const,
}, {
type: 'link' as const,
text: i18n.ts._fileViewer.title,
icon: 'ti ti-info-circle',
to: `/my/drive/file/${props.image.id}`,
}] : [])], ev.currentTarget ?? ev.target);
}

View File

@@ -94,7 +94,7 @@ import * as os from '@/os.js';
import { isFullscreenNotSupported } from '@/scripts/device-kind.js';
import hasAudio from '@/scripts/media-has-audio.js';
import MkMediaRange from '@/components/MkMediaRange.vue';
import { iAmModerator } from '@/account.js';
import { $i, iAmModerator } from '@/account.js';
const props = defineProps<{
video: Misskey.entities.DriveFile;
@@ -122,8 +122,6 @@ function showMenu(ev: MouseEvent) {
if (iAmModerator) {
menu.push({
type: 'divider',
}, {
text: props.video.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive,
icon: props.video.isSensitive ? 'ti ti-eye' : 'ti ti-eye-exclamation',
danger: true,
@@ -131,6 +129,17 @@ function showMenu(ev: MouseEvent) {
});
}
if ($i?.id === props.video.userId) {
menu.push({
type: 'divider',
}, {
type: 'link' as const,
text: i18n.ts._fileViewer.title,
icon: 'ti ti-info-circle',
to: `/my/drive/file/${props.video.id}`,
});
}
menuShowing.value = true;
os.popupMenu(menu, ev.currentTarget ?? ev.target, {
align: 'right',

View File

@@ -93,7 +93,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA>
</div>
<MkReactionsViewer :note="appearNote" :maxNumber="16" @mockUpdateMyReaction="emitUpdReaction">
<MkReactionsViewer v-if="appearNote.reactionAcceptance !== 'likeOnly'" :note="appearNote" :maxNumber="16" @mockUpdateMyReaction="emitUpdReaction">
<template #more>
<div :class="$style.reactionOmitted">{{ i18n.ts.more }}</div>
</template>
@@ -101,7 +101,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<footer :class="$style.footer">
<button :class="$style.footerButton" class="_button" @click="reply()">
<i class="ti ti-arrow-back-up"></i>
<p v-if="appearNote.repliesCount > 0" :class="$style.footerButtonCount">{{ appearNote.repliesCount }}</p>
<p v-if="appearNote.repliesCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.repliesCount) }}</p>
</button>
<button
v-if="canRenote"
@@ -111,17 +111,17 @@ SPDX-License-Identifier: AGPL-3.0-only
@mousedown="renote()"
>
<i class="ti ti-repeat"></i>
<p v-if="appearNote.renoteCount > 0" :class="$style.footerButtonCount">{{ appearNote.renoteCount }}</p>
<p v-if="appearNote.renoteCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.renoteCount) }}</p>
</button>
<button v-else :class="$style.footerButton" class="_button" disabled>
<i class="ti ti-ban"></i>
</button>
<button v-if="appearNote.myReaction == null" ref="reactButton" :class="$style.footerButton" class="_button" @mousedown="react()">
<i v-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i>
<button ref="reactButton" :class="$style.footerButton" class="_button" @click="toggleReact()">
<i v-if="appearNote.reactionAcceptance === 'likeOnly' && appearNote.myReaction != null" class="ti ti-heart-filled" style="color: var(--eventReactionHeart);"></i>
<i v-else-if="appearNote.myReaction != null" class="ti ti-minus" style="color: var(--accent);"></i>
<i v-else-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i>
<i v-else class="ti ti-plus"></i>
</button>
<button v-if="appearNote.myReaction != null" ref="reactButton" :class="$style.footerButton" class="_button" @click="undoReact(appearNote)">
<i class="ti ti-minus"></i>
<p v-if="(appearNote.reactionAcceptance === 'likeOnly' || defaultStore.state.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.reactionCount) }}</p>
</button>
<button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown="clip()">
<i class="ti ti-paperclip"></i>
@@ -175,6 +175,7 @@ import { pleaseLogin } from '@/scripts/please-login.js';
import { focusPrev, focusNext } from '@/scripts/focus.js';
import { checkWordMute } from '@/scripts/check-word-mute.js';
import { userPage } from '@/filters/user.js';
import number from '@/filters/number.js';
import * as os from '@/os.js';
import * as sound from '@/scripts/sound.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
@@ -420,6 +421,14 @@ function undoReact(targetNote: Misskey.entities.Note): void {
});
}
function toggleReact() {
if (appearNote.value.myReaction == null) {
react();
} else {
undoReact(appearNote.value);
}
}
function onContextmenu(ev: MouseEvent): void {
if (props.mock) {
return;

View File

@@ -106,10 +106,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkTime :time="appearNote.createdAt" mode="detail" colored/>
</MkA>
</div>
<MkReactionsViewer ref="reactionsViewer" :note="appearNote"/>
<MkReactionsViewer v-if="appearNote.reactionAcceptance !== 'likeOnly'" ref="reactionsViewer" :note="appearNote"/>
<button class="_button" :class="$style.noteFooterButton" @click="reply()">
<i class="ti ti-arrow-back-up"></i>
<p v-if="appearNote.repliesCount > 0" :class="$style.noteFooterButtonCount">{{ appearNote.repliesCount }}</p>
<p v-if="appearNote.repliesCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.repliesCount) }}</p>
</button>
<button
v-if="canRenote"
@@ -119,17 +119,17 @@ SPDX-License-Identifier: AGPL-3.0-only
@mousedown="renote()"
>
<i class="ti ti-repeat"></i>
<p v-if="appearNote.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ appearNote.renoteCount }}</p>
<p v-if="appearNote.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.renoteCount) }}</p>
</button>
<button v-else class="_button" :class="$style.noteFooterButton" disabled>
<i class="ti ti-ban"></i>
</button>
<button v-if="appearNote.myReaction == null" ref="reactButton" :class="$style.noteFooterButton" class="_button" @mousedown="react()">
<i v-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i>
<button ref="reactButton" :class="$style.noteFooterButton" class="_button" @click="toggleReact()">
<i v-if="appearNote.reactionAcceptance === 'likeOnly' && appearNote.myReaction != null" class="ti ti-heart-filled" style="color: var(--eventReactionHeart);"></i>
<i v-else-if="appearNote.myReaction != null" class="ti ti-minus" style="color: var(--accent);"></i>
<i v-else-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i>
<i v-else class="ti ti-plus"></i>
</button>
<button v-if="appearNote.myReaction != null" ref="reactButton" class="_button" :class="[$style.noteFooterButton, $style.reacted]" @click="undoReact(appearNote)">
<i class="ti ti-minus"></i>
<p v-if="(appearNote.reactionAcceptance === 'likeOnly' || defaultStore.state.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.reactionCount) }}</p>
</button>
<button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown="clip()">
<i class="ti ti-paperclip"></i>
@@ -209,6 +209,7 @@ import { pleaseLogin } from '@/scripts/please-login.js';
import { checkWordMute } from '@/scripts/check-word-mute.js';
import { userPage } from '@/filters/user.js';
import { notePage } from '@/filters/note.js';
import number from '@/filters/number.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import * as sound from '@/scripts/sound.js';
@@ -401,14 +402,22 @@ function react(viaKeyboard = false): void {
}
}
function undoReact(note): void {
const oldReaction = note.myReaction;
function undoReact(targetNote: Misskey.entities.Note): void {
const oldReaction = targetNote.myReaction;
if (!oldReaction) return;
misskeyApi('notes/reactions/delete', {
noteId: note.id,
noteId: targetNote.id,
});
}
function toggleReact() {
if (appearNote.value.myReaction == null) {
react();
} else {
undoReact(appearNote.value);
}
}
function onContextmenu(ev: MouseEvent): void {
const isLink = (el: HTMLElement): boolean => {
if (el.tagName === 'A') return true;

View File

@@ -8,6 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.head">
<MkAvatar v-if="['pollEnded', 'note'].includes(notification.type) && notification.note" :class="$style.icon" :user="notification.note.user" link preview/>
<MkAvatar v-else-if="['roleAssigned', 'achievementEarned'].includes(notification.type)" :class="$style.icon" :user="$i" link preview/>
<div v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'" :class="[$style.icon, $style.icon_reactionGroupHeart]"><i class="ti ti-heart" style="line-height: 1;"></i></div>
<div v-else-if="notification.type === 'reaction:grouped'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ti ti-plus" style="line-height: 1;"></i></div>
<div v-else-if="notification.type === 'renote:grouped'" :class="[$style.icon, $style.icon_renoteGroup]"><i class="ti ti-repeat" style="line-height: 1;"></i></div>
<img v-else-if="notification.type === 'test'" :class="$style.icon" :src="infoImageUrl"/>
@@ -57,6 +58,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-else-if="notification.type === 'achievementEarned'">{{ i18n.ts._notification.achievementEarned }}</span>
<span v-else-if="notification.type === 'test'">{{ i18n.ts._notification.testNotification }}</span>
<MkA v-else-if="notification.type === 'follow' || notification.type === 'mention' || notification.type === 'reply' || notification.type === 'renote' || notification.type === 'quote' || notification.type === 'reaction' || notification.type === 'receiveFollowRequest' || notification.type === 'followRequestAccepted'" v-user-preview="notification.user.id" :class="$style.headerName" :to="userPage(notification.user)"><MkUserName :user="notification.user"/></MkA>
<span v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'">{{ i18n.tsx._notification.likedBySomeUsers({ n: notification.reactions.length }) }}</span>
<span v-else-if="notification.type === 'reaction:grouped'">{{ i18n.tsx._notification.reactedBySomeUsers({ n: notification.reactions.length }) }}</span>
<span v-else-if="notification.type === 'renote:grouped'">{{ i18n.tsx._notification.renotedBySomeUsers({ n: notification.users.length }) }}</span>
<span v-else-if="notification.type === 'app'">{{ notification.header }}</span>
@@ -201,6 +203,7 @@ const rejectFollowRequest = () => {
}
.icon_reactionGroup,
.icon_reactionGroupHeart,
.icon_renoteGroup {
display: grid;
align-items: center;
@@ -213,11 +216,15 @@ const rejectFollowRequest = () => {
}
.icon_reactionGroup {
background: #e99a0b;
background: var(--eventReaction);
}
.icon_reactionGroupHeart {
background: var(--eventReactionHeart);
}
.icon_renoteGroup {
background: #36d298;
background: var(--eventRenote);
}
.icon_app {
@@ -246,49 +253,49 @@ const rejectFollowRequest = () => {
.t_follow, .t_followRequestAccepted, .t_receiveFollowRequest {
padding: 3px;
background: #36aed2;
background: var(--eventFollow);
pointer-events: none;
}
.t_renote {
padding: 3px;
background: #36d298;
background: var(--eventRenote);
pointer-events: none;
}
.t_quote {
padding: 3px;
background: #36d298;
background: var(--eventRenote);
pointer-events: none;
}
.t_reply {
padding: 3px;
background: #007aff;
background: var(--eventReply);
pointer-events: none;
}
.t_mention {
padding: 3px;
background: #88a6b7;
background: var(--eventOther);
pointer-events: none;
}
.t_pollEnded {
padding: 3px;
background: #88a6b7;
background: var(--eventOther);
pointer-events: none;
}
.t_achievementEarned {
padding: 3px;
background: #cb9a11;
background: var(--eventAchievement);
pointer-events: none;
}
.t_roleAssigned {
padding: 3px;
background: #88a6b7;
background: var(--eventOther);
pointer-events: none;
}

View File

@@ -35,6 +35,7 @@ import { notificationTypes } from '@/const.js';
import { infoImageUrl } from '@/instance.js';
import { defaultStore } from '@/store.js';
import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
import * as Misskey from 'misskey-js';
const props = defineProps<{
excludeTypes?: typeof notificationTypes[number][];
@@ -75,17 +76,19 @@ function reload() {
});
}
let connection;
let connection: Misskey.ChannelConnection<Misskey.Channels['main']>;
onMounted(() => {
connection = useStream().useChannel('main');
connection.on('notification', onNotification);
connection.on('notificationFlushed', reload);
});
onActivated(() => {
pagingComponent.value?.reload();
connection = useStream().useChannel('main');
connection.on('notification', onNotification);
connection.on('notificationFlushed', reload);
});
onUnmounted(() => {

View File

@@ -172,7 +172,7 @@ const emit = defineEmits<{
const textareaEl = shallowRef<HTMLTextAreaElement | null>(null);
const cwInputEl = shallowRef<HTMLInputElement | null>(null);
const hashtagsInputEl = shallowRef<HTMLInputElement | null>(null);
const visibilityButton = shallowRef<HTMLElement | null>(null);
const visibilityButton = shallowRef<HTMLElement>();
const posting = ref(false);
const posted = ref(false);
@@ -461,6 +461,7 @@ function setVisibility() {
isSilenced: $i.isSilenced,
localOnly: localOnly.value,
src: visibilityButton.value,
...(props.reply ? { isReplyVisibilitySpecified: props.reply.visibility === 'specified' } : {}),
}, {
changeVisibility: v => {
visibility.value = v;

View File

@@ -152,11 +152,11 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent): void {
icon: 'ti ti-crop',
action: () : void => { crop(file); },
}] : [], {
type: 'divider',
}, {
text: i18n.ts.attachCancel,
icon: 'ti ti-circle-x',
action: () => { detachMedia(file.id); },
}, {
type: 'divider',
}, {
text: i18n.ts.deleteFile,
icon: 'ti ti-trash',

View File

@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkCustomEmoji v-if="reaction[0] === ':'" ref="elRef" :name="reaction" :normal="true" :noStyle="noStyle" :url="emojiUrl"/>
<MkCustomEmoji v-if="reaction[0] === ':'" ref="elRef" :name="reaction" :normal="true" :noStyle="noStyle" :url="emojiUrl" :fallbackToImage="true"/>
<MkEmoji v-else ref="elRef" :emoji="reaction" :normal="true" :noStyle="noStyle"/>
</template>

View File

@@ -44,7 +44,7 @@ function getReactionName(reaction: string): string {
if (trimLocal.startsWith(':')) {
return trimLocal;
}
return getEmojiName(reaction) ?? reaction;
return getEmojiName(reaction);
}
</script>

View File

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

View File

@@ -63,6 +63,7 @@ const exampleNote = reactive<Misskey.entities.Note>({
reactionAcceptance: null,
renoteCount: 0,
repliesCount: 1,
reactionCount: 0,
reactions: {},
reactionEmojis: {},
fileIds: [],

View File

@@ -68,6 +68,7 @@ const exampleCWNote = reactive<Misskey.entities.Note>({
reactionAcceptance: null,
renoteCount: 0,
repliesCount: 1,
reactionCount: 0,
reactions: {},
reactionEmojis: {},
fileIds: [],

View File

@@ -58,6 +58,7 @@ const exampleNote = reactive<Misskey.entities.Note>({
reactionAcceptance: null,
renoteCount: 0,
repliesCount: 1,
reactionCount: 0,
reactions: {},
reactionEmojis: {},
fileIds: ['0000000002'],

View File

@@ -9,21 +9,21 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="[$style.label, $style.item]">
{{ i18n.ts.visibility }}
</div>
<button key="public" :disabled="isSilenced" class="_button" :class="[$style.item, { [$style.active]: v === 'public' }]" data-index="1" @click="choose('public')">
<button key="public" :disabled="isSilenced || isReplyVisibilitySpecified" class="_button" :class="[$style.item, { [$style.active]: v === 'public' }]" data-index="1" @click="choose('public')">
<div :class="$style.icon"><i class="ti ti-world"></i></div>
<div :class="$style.body">
<span :class="$style.itemTitle">{{ i18n.ts._visibility.public }}</span>
<span :class="$style.itemDescription">{{ i18n.ts._visibility.publicDescription }}</span>
</div>
</button>
<button key="home" class="_button" :class="[$style.item, { [$style.active]: v === 'home' }]" data-index="2" @click="choose('home')">
<button key="home" :disabled="isReplyVisibilitySpecified" class="_button" :class="[$style.item, { [$style.active]: v === 'home' }]" data-index="2" @click="choose('home')">
<div :class="$style.icon"><i class="ti ti-home"></i></div>
<div :class="$style.body">
<span :class="$style.itemTitle">{{ i18n.ts._visibility.home }}</span>
<span :class="$style.itemDescription">{{ i18n.ts._visibility.homeDescription }}</span>
</div>
</button>
<button key="followers" class="_button" :class="[$style.item, { [$style.active]: v === 'followers' }]" data-index="3" @click="choose('followers')">
<button key="followers" :disabled="isReplyVisibilitySpecified" class="_button" :class="[$style.item, { [$style.active]: v === 'followers' }]" data-index="3" @click="choose('followers')">
<div :class="$style.icon"><i class="ti ti-lock"></i></div>
<div :class="$style.body">
<span :class="$style.itemTitle">{{ i18n.ts._visibility.followers }}</span>
@@ -54,6 +54,7 @@ const props = withDefaults(defineProps<{
isSilenced: boolean;
localOnly: boolean;
src?: HTMLElement;
isReplyVisibilitySpecified?: boolean;
}>(), {
});

View File

@@ -32,7 +32,8 @@ export const Default = {
async play({ canvasElement }) {
const canvas = within(canvasElement);
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 tick();
const menu = canvas.getByRole('menu');
@@ -44,6 +45,7 @@ export const Default = {
},
args: {
to: '#test',
behavior: 'browser',
},
parameters: {
layout: 'centered',

View File

@@ -14,10 +14,20 @@ SPDX-License-Identifier: AGPL-3.0-only
[$style.form_vertical]: chosen.place === 'vertical',
}]"
>
<a :href="chosen.url" target="_blank" :class="$style.link">
<component
:is="self ? 'MkA' : 'a'"
:class="$style.link"
v-bind="self ? {
to: chosen.url.substring(local.length),
} : {
href: chosen.url,
rel: 'nofollow noopener',
target: '_blank',
}"
>
<img :src="chosen.imageUrl" :class="$style.img">
<button class="_button" :class="$style.i" @click.prevent.stop="toggleMenu"><i :class="$style.iIcon" class="ti ti-info-circle"></i></button>
</a>
</component>
</div>
<div v-else :class="$style.menu">
<div :class="$style.menuContainer">
@@ -32,10 +42,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { ref, computed } from 'vue';
import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js';
import { host } from '@/config.js';
import { url as local, host } from '@/config.js';
import MkButton from '@/components/MkButton.vue';
import { defaultStore } from '@/store.js';
import * as os from '@/os.js';
@@ -96,6 +106,9 @@ const choseAd = (): Ad | null => {
};
const chosen = ref(choseAd());
const self = computed(() => chosen.value?.url.startsWith(local));
const shouldHide = ref(!defaultStore.state.forceShowAds && $i && $i.policies.canHideAds && (props.specify == null));
function reduceFrequency(): void {

View File

@@ -48,3 +48,18 @@ export const Missing = {
name: Default.args.name,
},
} satisfies StoryObj<typeof MkCustomEmoji>;
export const ErrorToText = {
...Default,
args: {
url: 'https://example.com/404',
name: Default.args.name,
},
} satisfies StoryObj<typeof MkCustomEmoji>;
export const ErrorToImage = {
...Default,
args: {
url: 'https://example.com/404',
name: Default.args.name,
fallbackToImage: true,
},
} satisfies StoryObj<typeof MkCustomEmoji>;

View File

@@ -4,7 +4,13 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<span v-if="errored">:{{ customEmojiName }}:</span>
<img
v-if="errored && fallbackToImage"
:class="[$style.root, { [$style.normal]: normal, [$style.noStyle]: noStyle }]"
src="/client-assets/dummy.png"
:title="alt"
/>
<span v-else-if="errored">:{{ customEmojiName }}:</span>
<img
v-else
:class="[$style.root, { [$style.normal]: normal, [$style.noStyle]: noStyle }]"
@@ -39,6 +45,7 @@ const props = defineProps<{
useOriginalSize?: boolean;
menu?: boolean;
menuReaction?: boolean;
fallbackToImage?: boolean;
}>();
const react = inject<((name: string) => void) | null>('react', null);

View File

@@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, inject } from 'vue';
import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@/scripts/emoji-base.js';
import { char2fluentEmojiFilePath, char2twemojiFilePath } from '@/scripts/emoji-base.js';
import { defaultStore } from '@/store.js';
import { colorizeEmoji, getEmojiName } from '@/scripts/emojilist.js';
import * as os from '@/os.js';
@@ -34,8 +34,7 @@ const colorizedNativeEmoji = computed(() => colorizeEmoji(props.emoji));
// Searching from an array with 2000 items for every emoji felt like too energy-consuming, so I decided to do it lazily on pointerenter
function computeTitle(event: PointerEvent): void {
const title = getEmojiName(props.emoji as string) ?? props.emoji as string;
(event.target as HTMLElement).title = title;
(event.target as HTMLElement).title = getEmojiName(props.emoji);
}
function onClick(ev: MouseEvent) {

View File

@@ -412,6 +412,7 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven
useOriginalSize: scale >= 2.5,
menu: props.enableEmojiMenu,
menuReaction: props.enableEmojiMenuReaction,
fallbackToImage: false,
})];
} else {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition

View File

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

View File

@@ -75,6 +75,7 @@ export const ROLE_POLICIES = [
'gtlAvailable',
'ltlAvailable',
'canPublicNote',
'mentionLimit',
'canInvite',
'inviteLimit',
'inviteLimitCycle',

View File

@@ -99,7 +99,6 @@ export class UserPreview {
this.el.removeEventListener('mouseover', this.onMouseover);
this.el.removeEventListener('mouseleave', this.onMouseleave);
this.el.removeEventListener('click', this.onClick);
window.clearInterval(this.checkTimer);
}
}

View File

@@ -11,13 +11,24 @@ import { DEFAULT_INFO_IMAGE_URL, DEFAULT_NOT_FOUND_IMAGE_URL, DEFAULT_SERVER_ERR
// TODO: 他のタブと永続化されたstateを同期
const cached = miLocalStorage.getItem('instance');
//#region loader
const providedMetaEl = document.getElementById('misskey_meta');
let cachedMeta = miLocalStorage.getItem('instance') ? JSON.parse(miLocalStorage.getItem('instance')!) : null;
let cachedAt = miLocalStorage.getItem('instanceCachedAt') ? parseInt(miLocalStorage.getItem('instanceCachedAt')!) : 0;
const providedMeta = providedMetaEl && providedMetaEl.textContent ? JSON.parse(providedMetaEl.textContent) : null;
const providedAt = providedMetaEl && providedMetaEl.dataset.generatedAt ? parseInt(providedMetaEl.dataset.generatedAt) : 0;
if (providedAt > cachedAt) {
miLocalStorage.setItem('instance', JSON.stringify(providedMeta));
miLocalStorage.setItem('instanceCachedAt', providedAt.toString());
cachedMeta = providedMeta;
cachedAt = providedAt;
}
//#endregion
// TODO: instanceをリアクティブにするかは再考の余地あり
export const instance: Misskey.entities.MetaResponse = reactive(cached ? JSON.parse(cached) : {
// TODO: set default values
});
export const instance: Misskey.entities.MetaResponse = reactive(cachedMeta ?? {});
export const serverErrorImageUrl = computed(() => instance.serverErrorImageUrl ?? DEFAULT_SERVER_ERROR_IMAGE_URL);
@@ -25,7 +36,15 @@ export const infoImageUrl = computed(() => instance.infoImageUrl ?? DEFAULT_INFO
export const notFoundImageUrl = computed(() => instance.notFoundImageUrl ?? DEFAULT_NOT_FOUND_IMAGE_URL);
export async function fetchInstance() {
export async function fetchInstance(force = false): Promise<void> {
if (!force) {
const cachedAt = miLocalStorage.getItem('instanceCachedAt') ? parseInt(miLocalStorage.getItem('instanceCachedAt')!) : 0;
if (Date.now() - cachedAt < 1000 * 60 * 60) {
return;
}
}
const meta = await misskeyApi('meta', {
detail: false,
});
@@ -35,4 +54,5 @@ export async function fetchInstance() {
}
miLocalStorage.setItem('instance', JSON.stringify(instance));
miLocalStorage.setItem('instanceCachedAt', Date.now().toString());
}

View File

@@ -7,6 +7,7 @@ type Keys =
'v' |
'lastVersion' |
'instance' |
'instanceCachedAt' |
'account' |
'accounts' |
'latestDonationInfoShownAt' |

View File

@@ -373,7 +373,7 @@ export class Router extends EventEmitter<RouterEvent> implements IRouter {
this.currentRoute.value = res.route;
this.currentKey = res.route.globalCacheKey ?? key ?? path;
if (emitChange) {
if (emitChange && res.route.path !== '/:(*)') {
this.emit('change', {
beforePath,
path,
@@ -408,13 +408,17 @@ export class Router extends EventEmitter<RouterEvent> implements IRouter {
if (cancel) return;
}
const res = this.navigate(path, null);
this.emit('push', {
beforePath,
path: res._parsedRoute.fullPath,
route: res.route,
props: res.props,
key: this.currentKey,
});
if (res.route.path === '/:(*)') {
location.href = path;
} else {
this.emit('push', {
beforePath,
path: res._parsedRoute.fullPath,
route: res.route,
props: res.props,
key: this.currentKey,
});
}
}
public replace(path: string, key?: string | null) {

View File

@@ -7,9 +7,9 @@
import { Component, markRaw, Ref, ref, defineAsyncComponent } from 'vue';
import { EventEmitter } from 'eventemitter3';
import insertTextAtCursor from 'insert-text-at-cursor';
import * as Misskey from 'misskey-js';
import type { ComponentProps } from 'vue-component-type-helpers';
import type { ComponentProps as CP } from 'vue-component-type-helpers';
import type { Form, GetFormResultType } from '@/scripts/form.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js';
import MkPostFormDialog from '@/components/MkPostFormDialog.vue';
@@ -19,7 +19,6 @@ import MkToast from '@/components/MkToast.vue';
import MkDialog from '@/components/MkDialog.vue';
import MkPasswordDialog from '@/components/MkPasswordDialog.vue';
import MkEmojiPickerDialog from '@/components/MkEmojiPickerDialog.vue';
import MkEmojiPickerWindow from '@/components/MkEmojiPickerWindow.vue';
import MkPopupMenu from '@/components/MkPopupMenu.vue';
import MkContextMenu from '@/components/MkContextMenu.vue';
import { MenuItem } from '@/types/menu.js';
@@ -28,15 +27,15 @@ import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
export const openingWindowsCount = ref(0);
export const apiWithDialog = ((
endpoint: string,
data: Record<string, any> = {},
export const apiWithDialog = (<E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints, P extends Misskey.Endpoints[E]['req'] = Misskey.Endpoints[E]['req']>(
endpoint: E,
data: P = {} as any,
token?: string | null | undefined,
) => {
const promise = misskeyApi(endpoint, data, token);
promiseDialog(promise, null, async (err) => {
let title = null;
let text = err.message + '\n' + (err as any).id;
let title: string | undefined;
let text = err.message + '\n' + err.id;
if (err.code === 'INTERNAL_ERROR') {
title = i18n.ts.internalServerError;
text = i18n.ts.internalServerErrorDescription;
@@ -88,7 +87,7 @@ export const apiWithDialog = ((
export function promiseDialog<T extends Promise<any>>(
promise: T,
onSuccess?: ((res: any) => void) | null,
onFailure?: ((err: Error) => void) | null,
onFailure?: ((err: Misskey.api.APIError) => void) | null,
text?: string,
): T {
const showing = ref(true);
@@ -149,14 +148,30 @@ export function claimZIndex(priority: keyof typeof zIndexes = 'low'): number {
// 使い物にならないので、代わりに ['$props'] から色々省くことで emit の型を生成する
// FIXME: 何故か *.ts ファイルからだと型がうまく取れない?ことがあるのをなんとかしたい
type ComponentEmit<T> = T extends new () => { $props: infer Props }
? EmitsExtractor<Props>
: never;
? [keyof Pick<T, Extract<keyof T, `on${string}`>>] extends [never]
? Record<string, unknown> // *.ts ファイルから型がうまく取れないとき用(これがないと {} になって型エラーがうるさい)
: EmitsExtractor<Props>
: T extends (...args: any) => any
? ReturnType<T> extends { [x: string]: any; __ctx?: { [x: string]: any; props: infer Props } }
? [keyof Pick<T, Extract<keyof T, `on${string}`>>] extends [never]
? Record<string, unknown>
: EmitsExtractor<Props>
: never
: never;
// props に ref を許可するようにする
type ComponentProps<T extends Component> = { [K in keyof CP<T>]: CP<T>[K] | Ref<CP<T>[K]> };
type EmitsExtractor<T> = {
[K in keyof T as K extends `onVnode${string}` ? never : K extends `on${infer E}` ? Uncapitalize<E> : K extends string ? never : K]: T[K];
};
export async function popup<T extends Component>(component: T, props: ComponentProps<T>, events: ComponentEmit<T> = {} as ComponentEmit<T>, disposeEvent?: keyof ComponentEmit<T>) {
export async function popup<T extends Component>(
component: T,
props: ComponentProps<T>,
events: ComponentEmit<T> = {} as ComponentEmit<T>,
disposeEvent?: keyof ComponentEmit<T>,
): Promise<{ dispose: () => void }> {
markRaw(component);
const id = ++popupIdCount;
@@ -197,12 +212,12 @@ export function toast(message: string) {
export function alert(props: {
type?: 'error' | 'info' | 'success' | 'warning' | 'waiting' | 'question';
title?: string | null;
text?: string | null;
title?: string;
text?: string;
}): Promise<void> {
return new Promise((resolve, reject) => {
return new Promise(resolve => {
popup(MkDialog, props, {
done: result => {
done: () => {
resolve();
},
}, 'closed');
@@ -211,12 +226,12 @@ export function alert(props: {
export function confirm(props: {
type: 'error' | 'info' | 'success' | 'warning' | 'waiting' | 'question';
title?: string | null;
text?: string | null;
title?: string;
text?: string;
okText?: string;
cancelText?: string;
}): Promise<{ canceled: boolean }> {
return new Promise((resolve, reject) => {
return new Promise(resolve => {
popup(MkDialog, {
...props,
showCancelButton: true,
@@ -237,13 +252,15 @@ export function actions<T extends {
danger?: boolean,
}[]>(props: {
type: 'error' | 'info' | 'success' | 'warning' | 'waiting' | 'question';
title?: string | null;
text?: string | null;
title?: string;
text?: string;
actions: T;
}): Promise<{ canceled: true; result: undefined; } | {
}): Promise<{
canceled: true; result: undefined;
} | {
canceled: false; result: T[number]['value'];
}> {
return new Promise((resolve, reject) => {
return new Promise(resolve => {
popup(MkDialog, {
...props,
actions: props.actions.map(a => ({
@@ -262,19 +279,50 @@ export function actions<T extends {
});
}
// default が指定されていたら result は null になり得ないことを保証する overload function
export function inputText(props: {
type?: 'text' | 'email' | 'password' | 'url';
title?: string | null;
text?: string | null;
title?: string;
text?: string;
placeholder?: string | null;
autocomplete?: string;
default: string;
minLength?: number;
maxLength?: number;
}): Promise<{
canceled: true; result: undefined;
} | {
canceled: false; result: string;
}>;
export function inputText(props: {
type?: 'text' | 'email' | 'password' | 'url';
title?: string;
text?: string;
placeholder?: string | null;
autocomplete?: string;
default?: string | null;
minLength?: number;
maxLength?: number;
}): Promise<{ canceled: true; result: undefined; } | {
canceled: false; result: string;
}): Promise<{
canceled: true; result: undefined;
} | {
canceled: false; result: string | null;
}>;
export function inputText(props: {
type?: 'text' | 'email' | 'password' | 'url';
title?: string;
text?: string;
placeholder?: string | null;
autocomplete?: string;
default?: string | null;
minLength?: number;
maxLength?: number;
}): Promise<{
canceled: true; result: undefined;
} | {
canceled: false; result: string | null;
}> {
return new Promise((resolve, reject) => {
return new Promise(resolve => {
popup(MkDialog, {
title: props.title,
text: props.text,
@@ -282,7 +330,7 @@ export function inputText(props: {
type: props.type,
placeholder: props.placeholder,
autocomplete: props.autocomplete,
default: props.default,
default: props.default ?? null,
minLength: props.minLength,
maxLength: props.maxLength,
},
@@ -294,16 +342,41 @@ export function inputText(props: {
});
}
// default が指定されていたら result は null になり得ないことを保証する overload function
export function inputNumber(props: {
title?: string | null;
text?: string | null;
title?: string;
text?: string;
placeholder?: string | null;
autocomplete?: string;
default: number;
}): Promise<{
canceled: true; result: undefined;
} | {
canceled: false; result: number;
}>;
export function inputNumber(props: {
title?: string;
text?: string;
placeholder?: string | null;
autocomplete?: string;
default?: number | null;
}): Promise<{ canceled: true; result: undefined; } | {
canceled: false; result: number;
}): Promise<{
canceled: true; result: undefined;
} | {
canceled: false; result: number | null;
}>;
export function inputNumber(props: {
title?: string;
text?: string;
placeholder?: string | null;
autocomplete?: string;
default?: number | null;
}): Promise<{
canceled: true; result: undefined;
} | {
canceled: false; result: number | null;
}> {
return new Promise((resolve, reject) => {
return new Promise(resolve => {
popup(MkDialog, {
title: props.title,
text: props.text,
@@ -311,7 +384,7 @@ export function inputNumber(props: {
type: 'number',
placeholder: props.placeholder,
autocomplete: props.autocomplete,
default: props.default,
default: props.default ?? null,
},
}, {
done: result => {
@@ -322,34 +395,38 @@ export function inputNumber(props: {
}
export function inputDate(props: {
title?: string | null;
text?: string | null;
title?: string;
text?: string;
placeholder?: string | null;
default?: Date | null;
}): Promise<{ canceled: true; result: undefined; } | {
default?: string | null;
}): Promise<{
canceled: true; result: undefined;
} | {
canceled: false; result: Date;
}> {
return new Promise((resolve, reject) => {
return new Promise(resolve => {
popup(MkDialog, {
title: props.title,
text: props.text,
input: {
type: 'date',
placeholder: props.placeholder,
default: props.default,
default: props.default ?? null,
},
}, {
done: result => {
resolve(result ? { result: new Date(result.result), canceled: false } : { canceled: true });
resolve(result ? { result: new Date(result.result), canceled: false } : { result: undefined, canceled: true });
},
}, 'closed');
});
}
export function authenticateDialog(): Promise<{ canceled: true; result: undefined; } | {
export function authenticateDialog(): Promise<{
canceled: true; result: undefined;
} | {
canceled: false; result: { password: string; token: string | null; };
}> {
return new Promise((resolve, reject) => {
return new Promise(resolve => {
popup(MkPasswordDialog, {}, {
done: result => {
resolve(result ? { canceled: false, result } : { canceled: true, result: undefined });
@@ -358,34 +435,53 @@ export function authenticateDialog(): Promise<{ canceled: true; result: undefine
});
}
// default が指定されていたら result は null になり得ないことを保証する overload function
export function select<C = any>(props: {
title?: string | null;
text?: string | null;
default?: string | null;
} & ({
title?: string;
text?: string;
default: string;
items: {
value: C;
text: string;
}[];
}): Promise<{
canceled: true; result: undefined;
} | {
groupedItems: {
label: string;
items: {
value: C;
text: string;
}[];
}[];
})): Promise<{ canceled: true; result: undefined; } | {
canceled: false; result: C;
}>;
export function select<C = any>(props: {
title?: string;
text?: string;
default?: string | null;
items: {
value: C;
text: string;
}[];
}): Promise<{
canceled: true; result: undefined;
} | {
canceled: false; result: C | null;
}>;
export function select<C = any>(props: {
title?: string;
text?: string;
default?: string | null;
items: {
value: C;
text: string;
}[];
}): Promise<{
canceled: true; result: undefined;
} | {
canceled: false; result: C | null;
}> {
return new Promise((resolve, reject) => {
return new Promise(resolve => {
popup(MkDialog, {
title: props.title,
text: props.text,
select: {
items: props.items,
groupedItems: props.groupedItems,
default: props.default,
default: props.default ?? null,
},
}, {
done: result => {
@@ -396,7 +492,7 @@ export function select<C = any>(props: {
}
export function success(): Promise<void> {
return new Promise((resolve, reject) => {
return new Promise(resolve => {
const showing = ref(true);
window.setTimeout(() => {
showing.value = false;
@@ -411,7 +507,7 @@ export function success(): Promise<void> {
}
export function waiting(): Promise<void> {
return new Promise((resolve, reject) => {
return new Promise(resolve => {
const showing = ref(true);
popup(MkWaitingDialog, {
success: false,
@@ -422,9 +518,9 @@ export function waiting(): Promise<void> {
});
}
export function form(title, form) {
return new Promise((resolve, reject) => {
popup(defineAsyncComponent(() => import('@/components/MkFormDialog.vue')), { title, form }, {
export function form<F extends Form>(title: string, f: F): Promise<{ canceled: true } | { result: GetFormResultType<F> }> {
return new Promise(resolve => {
popup(defineAsyncComponent(() => import('@/components/MkFormDialog.vue')), { title, form: f }, {
done: result => {
resolve(result);
},
@@ -433,7 +529,7 @@ export function form(title, form) {
}
export async function selectUser(opts: { includeSelf?: boolean; localOnly?: boolean; } = {}): Promise<Misskey.entities.UserDetailed> {
return new Promise((resolve, reject) => {
return new Promise(resolve => {
popup(defineAsyncComponent(() => import('@/components/MkUserSelectDialog.vue')), {
includeSelf: opts.includeSelf,
localOnly: opts.localOnly,
@@ -446,7 +542,7 @@ export async function selectUser(opts: { includeSelf?: boolean; localOnly?: bool
}
export async function selectDriveFile(multiple: boolean): Promise<Misskey.entities.DriveFile[]> {
return new Promise((resolve, reject) => {
return new Promise(resolve => {
popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), {
type: 'file',
multiple,
@@ -460,23 +556,23 @@ export async function selectDriveFile(multiple: boolean): Promise<Misskey.entiti
});
}
export async function selectDriveFolder(multiple: boolean) {
return new Promise((resolve, reject) => {
export async function selectDriveFolder(multiple: boolean): Promise<Misskey.entities.DriveFolder[]> {
return new Promise(resolve => {
popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), {
type: 'folder',
multiple,
}, {
done: folders => {
if (folders) {
resolve(multiple ? folders : folders[0]);
resolve(folders);
}
},
}, 'closed');
});
}
export async function pickEmoji(src: HTMLElement | null, opts) {
return new Promise((resolve, reject) => {
export async function pickEmoji(src: HTMLElement, opts: ComponentProps<typeof MkEmojiPickerDialog>): Promise<string> {
return new Promise(resolve => {
popup(MkEmojiPickerDialog, {
src,
...opts,
@@ -492,7 +588,7 @@ export async function cropImage(image: Misskey.entities.DriveFile, options: {
aspectRatio: number;
uploadFolder?: string | null;
}): Promise<Misskey.entities.DriveFile> {
return new Promise((resolve, reject) => {
return new Promise(resolve => {
popup(defineAsyncComponent(() => import('@/components/MkCropperDialog.vue')), {
file: image,
aspectRatio: options.aspectRatio,
@@ -505,67 +601,13 @@ export async function cropImage(image: Misskey.entities.DriveFile, options: {
});
}
type AwaitType<T> =
T extends Promise<infer U> ? U :
T extends (...args: any[]) => Promise<infer V> ? V :
T;
let openingEmojiPicker: AwaitType<ReturnType<typeof popup>> | null = null;
let activeTextarea: HTMLTextAreaElement | HTMLInputElement | null = null;
export async function openEmojiPicker(src?: HTMLElement, opts, initialTextarea: typeof activeTextarea) {
if (openingEmojiPicker) return;
activeTextarea = initialTextarea;
const textareas = document.querySelectorAll('textarea, input');
for (const textarea of Array.from(textareas)) {
textarea.addEventListener('focus', () => {
activeTextarea = textarea;
});
}
const observer = new MutationObserver(records => {
for (const record of records) {
for (const node of Array.from(record.addedNodes).filter(node => node instanceof HTMLElement) as HTMLElement[]) {
const textareas = node.querySelectorAll('textarea, input') as NodeListOf<NonNullable<typeof activeTextarea>>;
for (const textarea of Array.from(textareas).filter(textarea => textarea.dataset.preventEmojiInsert == null)) {
if (document.activeElement === textarea) activeTextarea = textarea;
textarea.addEventListener('focus', () => {
activeTextarea = textarea;
});
}
}
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: false,
characterData: false,
});
openingEmojiPicker = await popup(MkEmojiPickerWindow, {
src,
...opts,
}, {
chosen: emoji => {
insertTextAtCursor(activeTextarea, emoji);
},
closed: () => {
openingEmojiPicker!.dispose();
openingEmojiPicker = null;
observer.disconnect();
},
});
}
export function popupMenu(items: MenuItem[] | Ref<MenuItem[]>, src?: HTMLElement | EventTarget | null, options?: {
export function popupMenu(items: MenuItem[], src?: HTMLElement | EventTarget | null, options?: {
align?: string;
width?: number;
viaKeyboard?: boolean;
onClosing?: () => void;
}): Promise<void> {
return new Promise((resolve, reject) => {
return new Promise(resolve => {
let dispose;
popup(MkPopupMenu, {
items,
@@ -587,9 +629,9 @@ export function popupMenu(items: MenuItem[] | Ref<MenuItem[]>, src?: HTMLElement
});
}
export function contextMenu(items: MenuItem[] | Ref<MenuItem[]>, ev: MouseEvent): Promise<void> {
export function contextMenu(items: MenuItem[], ev: MouseEvent): Promise<void> {
ev.preventDefault();
return new Promise((resolve, reject) => {
return new Promise(resolve => {
let dispose;
popup(MkContextMenu, {
items,
@@ -608,7 +650,7 @@ export function contextMenu(items: MenuItem[] | Ref<MenuItem[]>, ev: MouseEvent)
export function post(props: Record<string, any> = {}): Promise<void> {
showMovedDialog();
return new Promise((resolve, reject) => {
return new Promise(resolve => {
// NOTE: MkPostFormDialogをdynamic importするとiOSでテキストエリアに自動フォーカスできない
// NOTE: ただ、dynamic importしない場合、MkPostFormDialogインスタンスが使いまわされ、
// Vueが渡されたコンポーネントに内部的に__propsというプロパティを生やす影響で、

View File

@@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="misskey">Misskey</div>
<div class="version">v{{ version }}</div>
<span v-for="emoji in easterEggEmojis" :key="emoji.id" class="emoji" :data-physics-x="emoji.left" :data-physics-y="emoji.top" :class="{ _physics_circle_: !emoji.emoji.startsWith(':') }">
<MkCustomEmoji v-if="emoji.emoji[0] === ':'" class="emoji" :name="emoji.emoji" :normal="true" :noStyle="true"/>
<MkCustomEmoji v-if="emoji.emoji[0] === ':'" class="emoji" :name="emoji.emoji" :normal="true" :noStyle="true" :fallbackToImage="true"/>
<MkEmoji v-else class="emoji" :emoji="emoji.emoji" :normal="true" :noStyle="true"/>
</span>
</div>
@@ -324,6 +324,7 @@ const patrons = [
'てば',
'たっくん',
'SHO SEKIGUCHI',
'塩キャベツ',
];
const thereIsTreasure = ref($i && !claimedAchievements.includes('foundTreasure'));

View File

@@ -9,6 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSelect v-model="type" :class="$style.typeSelect">
<option value="isLocal">{{ i18n.ts._role._condition.isLocal }}</option>
<option value="isRemote">{{ i18n.ts._role._condition.isRemote }}</option>
<option value="roleAssignedTo">{{ i18n.ts._role._condition.roleAssignedTo }}</option>
<option value="createdLessThan">{{ i18n.ts._role._condition.createdLessThan }}</option>
<option value="createdMoreThan">{{ i18n.ts._role._condition.createdMoreThan }}</option>
<option value="followersLessThanOrEq">{{ i18n.ts._role._condition.followersLessThanOrEq }}</option>
@@ -51,6 +52,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkInput v-else-if="['followersLessThanOrEq', 'followersMoreThanOrEq', 'followingLessThanOrEq', 'followingMoreThanOrEq', 'notesLessThanOrEq', 'notesMoreThanOrEq'].includes(type)" v-model="v.value" type="number">
</MkInput>
<MkSelect v-else-if="type === 'roleAssignedTo'" v-model="v.roleId">
<option v-for="role in roles.filter(r => r.target === 'manual')" :key="role.id" :value="role.id">{{ role.name }}</option>
</MkSelect>
</div>
</template>
@@ -62,6 +67,7 @@ import MkSelect from '@/components/MkSelect.vue';
import MkButton from '@/components/MkButton.vue';
import { i18n } from '@/i18n.js';
import { deepClone } from '@/scripts/clone.js';
import { rolesCache } from '@/cache.js';
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
@@ -77,6 +83,8 @@ const props = defineProps<{
const v = ref(deepClone(props.modelValue));
const roles = await rolesCache.fetch();
watch(() => props.modelValue, () => {
if (JSON.stringify(props.modelValue) === JSON.stringify(v.value)) return;
v.value = deepClone(props.modelValue);
@@ -92,6 +100,7 @@ const type = computed({
if (t === 'and') v.value.values = [];
if (t === 'or') v.value.values = [];
if (t === 'not') v.value.value = { id: uuid(), type: 'isRemote' };
if (t === 'roleAssignedTo') v.value.roleId = '';
if (t === 'createdLessThan') v.value.sec = 86400;
if (t === 'createdMoreThan') v.value.sec = 86400;
if (t === 'followersLessThanOrEq') v.value.value = 10;

View File

@@ -142,7 +142,7 @@ function save() {
turnstileSiteKey: turnstileSiteKey.value,
turnstileSecretKey: turnstileSecretKey.value,
}).then(() => {
fetchInstance();
fetchInstance(true);
});
}
</script>

View File

@@ -169,7 +169,7 @@ function save() {
feedbackUrl: feedbackUrl.value === '' ? null : feedbackUrl.value,
manifestJsonOverride: manifestJsonOverride.value === '' ? '{}' : JSON.stringify(JSON5.parse(manifestJsonOverride.value)),
}).then(() => {
fetchInstance();
fetchInstance(true);
});
}

View File

@@ -124,7 +124,7 @@ function save() {
smtpUser: smtpUser.value,
smtpPass: smtpPass.value,
}).then(() => {
fetchInstance();
fetchInstance(true);
});
}

View File

@@ -61,7 +61,7 @@ function save() {
deeplAuthKey: deeplAuthKey.value,
deeplIsPro: deeplIsPro.value,
}).then(() => {
fetchInstance();
fetchInstance(true);
});
}

View File

@@ -50,7 +50,7 @@ function save() {
silencedHosts: silencedHosts.value.split('\n') || [],
}).then(() => {
fetchInstance();
fetchInstance(true);
});
}

View File

@@ -110,7 +110,7 @@ function save() {
hiddenTags: hiddenTags.value.split('\n'),
preservedUsernames: preservedUsernames.value.split('\n'),
}).then(() => {
fetchInstance();
fetchInstance(true);
});
}

View File

@@ -110,6 +110,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<CodeDiff :context="5" :hideHeader="true" :oldString="JSON5.stringify(log.info.before, null, '\t')" :newString="JSON5.stringify(log.info.after, null, '\t')" language="javascript" maxHeight="300px"/>
</div>
</template>
<template v-else-if="log.type === 'updateRemoteInstanceNote'">
<div>{{ i18n.ts.user }}: {{ log.info.userId }}</div>
<div :class="$style.diff">
<CodeDiff :context="5" :hideHeader="true" :oldString="log.info.before ?? ''" :newString="log.info.after ?? ''" maxHeight="300px"/>
</div>
</template>
<details>
<summary>raw</summary>

View File

@@ -54,8 +54,6 @@ const pagination = {
})),
};
console.log(Misskey);
const headerActions = computed(() => []);
const headerTabs = computed(() => []);

View File

@@ -143,7 +143,7 @@ function save() {
objectStorageSetPublicRead: objectStorageSetPublicRead.value,
objectStorageS3ForcePathStyle: objectStorageS3ForcePathStyle.value,
}).then(() => {
fetchInstance();
fetchInstance(true);
});
}

View File

@@ -73,7 +73,7 @@ function save() {
enableChartsForRemoteUser: enableChartsForRemoteUser.value,
enableChartsForFederatedInstances: enableChartsForFederatedInstances.value,
}).then(() => {
fetchInstance();
fetchInstance(true);
});
}

View File

@@ -56,7 +56,7 @@ function save() {
os.apiWithDialog('admin/update-meta', {
proxyAccountId: proxyAccountId.value,
}).then(() => {
fetchInstance();
fetchInstance(true);
});
}

View File

@@ -160,6 +160,25 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.mentionMax, 'mentionLimit'])">
<template #label>{{ i18n.ts._role._options.mentionMax }}</template>
<template #suffix>
<span v-if="role.policies.mentionLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.mentionLimit.value }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.mentionLimit)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="role.policies.mentionLimit.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkInput v-model="role.policies.mentionLimit.value" :disabled="role.policies.mentionLimit.useDefault" type="number" :readonly="readonly">
</MkInput>
<MkRange v-model="role.policies.mentionLimit.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canInvite, 'canInvite'])">
<template #label>{{ i18n.ts._role._options.canInvite }}</template>
<template #suffix>

View File

@@ -48,6 +48,13 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.mentionMax, 'mentionLimit'])">
<template #label>{{ i18n.ts._role._options.mentionMax }}</template>
<template #suffix>{{ policies.mentionLimit }}</template>
<MkInput v-model="policies.mentionLimit" type="number">
</MkInput>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canInvite, 'canInvite'])">
<template #label>{{ i18n.ts._role._options.canInvite }}</template>
<template #suffix>{{ policies.canInvite ? i18n.ts.yes : i18n.ts.no }}</template>

View File

@@ -196,7 +196,7 @@ async function init() {
enableTruemailApi.value = meta.enableTruemailApi;
truemailInstance.value = meta.truemailInstance;
truemailAuthKey.value = meta.truemailAuthKey;
bannedEmailDomains.value = meta.bannedEmailDomains?.join('\n') || "";
bannedEmailDomains.value = meta.bannedEmailDomains?.join('\n') || '';
}
function save() {
@@ -221,7 +221,7 @@ function save() {
truemailAuthKey: truemailAuthKey.value,
bannedEmailDomains: bannedEmailDomains.value.split('\n'),
}).then(() => {
fetchInstance();
fetchInstance(true);
});
}

View File

@@ -58,7 +58,7 @@ const save = async () => {
await os.apiWithDialog('admin/update-meta', {
serverRules: serverRules.value,
});
fetchInstance();
fetchInstance(true);
};
const remove = (index: number): void => {

View File

@@ -243,7 +243,7 @@ async function save(): void {
notesPerOneAd: notesPerOneAd.value,
});
fetchInstance();
fetchInstance(true);
}
const headerTabs = computed(() => []);

View File

@@ -7,9 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSpacer :contentMax="800">
<div :class="$style.root">
<div v-if="!gameLoaded" :class="$style.loadingScreen">
<div>
Loading...
</div>
<div>{{ i18n.ts.loading }}<MkEllipsis/></div>
</div>
<!-- に対してTransitionコンポーネントを使うと何故かkeyを指定していてもキャッシュが効かず様々なコンポーネントが都度再評価されてパフォーマンスが低下する -->
<div v-show="gameLoaded" class="_gaps_s">
@@ -32,18 +30,18 @@ SPDX-License-Identifier: AGPL-3.0-only
</Transition>
<div :class="$style.header">
<div :class="[$style.frame, $style.headerTitle]">
<div :class="$style.frameInner">
<b>BUBBLE GAME</b>
<div>- {{ gameMode }} -</div>
<div class="_woodenFrame" :class="[$style.headerTitle]">
<div class="_woodenFrameInner">
<b>{{ i18n.ts.bubbleGame }}</b>
<div>- {{ gameMode.toUpperCase() }} -</div>
</div>
</div>
<div :class="[$style.frame, $style.frameH]">
<div :class="$style.frameInner">
<MkButton inline small @click="hold">HOLD</MkButton>
<div class="_woodenFrame _woodenFrameH">
<div class="_woodenFrameInner">
<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;"/>
</div>
<div :class="[$style.frameInner, $style.stock]" style="text-align: center;">
<div class="_woodenFrameInner" :class="$style.stock" style="text-align: center;">
<TransitionGroup
:enterActiveClass="$style.transition_stock_enterActive"
: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 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;"/>
<div>SCORE: <MkNumber :value="score"/>{{ getScoreUnit(gameMode) }}</div>
<div>MAX CHAIN: <MkNumber :value="maxCombo"/></div>
<div v-if="gameMode === 'yen'">TOTAL EARNINGS: <b><MkNumber :value="yenTotal ?? score"/></b></div>
<div v-if="gameMode === 'sweets'"><b>おにぎり<MkNumber :value="score / 130"/>個分</b></div>
<div>{{ i18n.ts._bubbleGame._score.score }}: <MkNumber :value="score"/>{{ getScoreUnit(gameMode) }}</div>
<div>{{ i18n.ts._bubbleGame._score.maxChain }}: <MkNumber :value="maxCombo"/></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>
<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 v-if="replaying" :class="$style.replayIndicator"><span :class="$style.replayIndicatorText"><i class="ti ti-player-play"></i> {{ i18n.ts.replaying }}</span></div>
</div>
<div v-if="replaying" :class="$style.frame">
<div :class="$style.frameInner">
<div v-if="replaying" class="_woodenFrame">
<div class="_woodenFrameInner">
<div style="background: #0004;">
<div style="height: 10px; background: var(--accent); will-change: width;" :style="{ width: `${(currentFrame / endedAtFrame) * 100}%` }"></div>
</div>
</div>
<div :class="$style.frameInner">
<div class="_woodenFrameInner">
<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 === 16" @click="replayPlaybackRate = replayPlaybackRate === 16 ? 1 : 16"><i class="ti ti-player-track-next"></i> x16</MkButton>
</div>
</div>
</div>
<div v-if="isGameOver" :class="$style.frame">
<div :class="$style.frameInner">
<div v-if="isGameOver" class="_woodenFrame">
<div class="_woodenFrameInner">
<div class="_buttonsCenter">
<MkButton primary rounded @click="backToTitle">{{ i18n.ts.backToTitle }}</MkButton>
<MkButton primary rounded @click="replay">{{ i18n.ts.showReplay }}</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 style="display: flex;">
<div :class="$style.frame" style="flex: 1; margin-right: 10px;">
<div :class="$style.frameInner">
<div>SCORE: <b><MkNumber :value="score"/>{{ getScoreUnit(gameMode) }}</b></div>
<div>HIGH SCORE: <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 class="_woodenFrame" style="flex: 1; margin-right: 10px;">
<div class="_woodenFrameInner">
<div>{{ i18n.ts._bubbleGame._score.score }}: <MkNumber :value="score"/>{{ getScoreUnit(gameMode) }}</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'">
{{ 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 :class="[$style.frame]" style="margin-left: auto;">
<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>
</div>
</div>
<div v-if="showConfig" :class="$style.frame">
<div :class="$style.frameInner">
<div v-if="showConfig" class="_woodenFrame">
<div class="_woodenFrameInner">
<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)">
<template #label>BGM {{ i18n.ts.volume }}</template>
@@ -153,8 +167,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
<div :class="$style.frame">
<div :class="$style.frameInner">
<div class="_woodenFrame">
<div class="_woodenFrameInner">
<div>FUSION RECIPE</div>
<div>
<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 :class="$style.frame">
<div :class="$style.frameInner">
<MkButton v-if="!isGameOver && !replaying" full danger @click="surrender">Surrender</MkButton>
<MkButton v-else full @click="restart">Retry</MkButton>
<div class="_woodenFrame">
<div class="_woodenFrameInner">
<MkButton v-if="!isGameOver && !replaying" full danger @click="surrender">{{ i18n.ts.surrender }}</MkButton>
<MkButton v-else full @click="restart">{{ i18n.ts.gameRetry }}</MkButton>
</div>
</div>
</div>
@@ -1313,38 +1327,6 @@ definePageMetadata(() => ({
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 {
position: relative;
z-index: 10;

View File

@@ -15,13 +15,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSpacer v-if="!gameStarted" :contentMax="800">
<div :class="$style.root">
<div class="_gaps">
<div :class="$style.frame" style="text-align: center;">
<div :class="$style.frameInner">
<div class="_woodenFrame" style="text-align: center;">
<div class="_woodenFrameInner">
<img src="/client-assets/drop-and-fusion/logo.png" style="display: block; max-width: 100%; max-height: 200px; margin: auto;"/>
</div>
</div>
<div :class="$style.frame" style="text-align: center;">
<div :class="$style.frameInner">
<div class="_woodenFrame" style="text-align: center;">
<div class="_woodenFrameInner">
<div class="_gaps" style="padding: 16px;">
<MkSelect v-model="gameMode">
<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>
</div>
</div>
<div :class="$style.frameInner">
<div class="_woodenFrameInner">
<div class="_gaps" style="padding: 16px;">
<div style="font-size: 90%;"><i class="ti ti-music"></i> {{ i18n.ts.soundWillBePlayed }}</div>
<MkSwitch v-model="mute">
@@ -42,10 +42,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
</div>
<div :class="$style.frame">
<div :class="$style.frameInner">
<div class="_woodenFrame">
<div class="_woodenFrameInner">
<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-for="r in ranking" :key="r.id" :class="$style.rankingRecord">
<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 :class="$style.frame">
<div :class="$style.frameInner" style="padding: 16px;">
<div class="_woodenFrame">
<div class="_woodenFrameInner" style="padding: 16px;">
<div style="font-weight: bold;">{{ i18n.ts._bubbleGame.howToPlay }}</div>
<ol>
<li>{{ i18n.ts._bubbleGame._howToPlay.section1 }}</li>
@@ -67,8 +67,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</ol>
</div>
</div>
<div :class="$style.frame">
<div :class="$style.frameInner">
<div class="_woodenFrame">
<div class="_woodenFrameInner">
<div class="_gaps_s" style="padding: 16px;">
<div><b>Credit</b></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 {
display: flex;
line-height: 24px;

View File

@@ -135,7 +135,7 @@ async function addRole() {
const { canceled, result: role } = await os.select({
items: roles.filter(r => r.isPublic).filter(r => !currentRoleIds.includes(r.id)).map(r => ({ text: r.name, value: r })),
});
if (canceled) return;
if (canceled || role == null) return;
rolesThatCanBeUsedThisEmojiAsReaction.value.push(role);
}

View File

@@ -39,6 +39,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSwitch v-model="isBlocked" :disabled="!meta || !instance" @update:modelValue="toggleBlock">{{ i18n.ts.blockThisInstance }}</MkSwitch>
<MkSwitch v-model="isSilenced" :disabled="!meta || !instance" @update:modelValue="toggleSilenced">{{ i18n.ts.silenceThisInstance }}</MkSwitch>
<MkButton @click="refreshMetadata"><i class="ti ti-refresh"></i> Refresh metadata</MkButton>
<MkTextarea v-model="moderationNote" manualSave>
<template #label>{{ i18n.ts.moderationNote }}</template>
</MkTextarea>
</div>
</FormSection>
@@ -119,7 +122,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import { ref, computed, watch } from 'vue';
import * as Misskey from 'misskey-js';
import MkChart from '@/components/MkChart.vue';
import MkObjectView from '@/components/MkObjectView.vue';
@@ -141,6 +144,7 @@ import MkPagination from '@/components/MkPagination.vue';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js';
import { dateString } from '@/filters/date.js';
import MkTextarea from '@/components/MkTextarea.vue';
const props = defineProps<{
host: string;
@@ -155,6 +159,7 @@ const suspended = ref(false);
const isBlocked = ref(false);
const isSilenced = ref(false);
const faviconUrl = ref<string | null>(null);
const moderationNote = ref('');
const usersPagination = {
endpoint: iAmModerator ? 'admin/show-users' : 'users' as const,
@@ -167,6 +172,10 @@ const usersPagination = {
offsetMode: true,
};
watch(moderationNote, async () => {
await misskeyApi('admin/federation/update-instance', { host: instance.value.host, moderationNote: moderationNote.value });
});
async function fetch(): Promise<void> {
if (iAmAdmin) {
meta.value = await misskeyApi('admin/meta');
@@ -178,6 +187,7 @@ async function fetch(): Promise<void> {
isBlocked.value = instance.value?.isBlocked ?? false;
isSilenced.value = instance.value?.isSilenced ?? false;
faviconUrl.value = getProxiedImageUrlNullable(instance.value?.faviconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.value?.iconUrl, 'preview');
moderationNote.value = instance.value?.moderationNote;
}
async function toggleBlock(): Promise<void> {

View File

@@ -52,7 +52,7 @@ const directNotesPagination = {
function setFilter(ev) {
const typeItems = notificationTypes.map(t => ({
text: i18n.ts._notification._types[t],
active: includeTypes.value && includeTypes.value.includes(t),
active: (includeTypes.value && includeTypes.value.includes(t)) ?? false,
action: () => {
includeTypes.value = [t];
},
@@ -63,7 +63,7 @@ function setFilter(ev) {
action: () => {
includeTypes.value = null;
},
}, { type: 'divider' }, ...typeItems] : typeItems;
}, { type: 'divider' as const }, ...typeItems] : typeItems;
os.popupMenu(items, ev.currentTarget ?? ev.target);
}

View File

@@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
<div :class="$style.board">
<div class="_woodenFrame">
<div :class="$style.boardInner">
<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>
@@ -124,8 +124,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkFolder>
<template #label>{{ i18n.ts.options }}</template>
<div class="_gaps_s" style="text-align: left;">
<MkSwitch v-model="showBoardLabels">Show labels</MkSwitch>
<MkSwitch v-model="useAvatarAsStone">useAvatarAsStone</MkSwitch>
<MkSwitch v-model="showBoardLabels">{{ i18n.ts._reversi.showBoardLabels }}</MkSwitch>
<MkSwitch v-model="useAvatarAsStone">{{ i18n.ts._reversi.useAvatarAsStone }}</MkSwitch>
</div>
</MkFolder>
@@ -248,7 +248,7 @@ if (game.value.isStarted && !game.value.isEnded) {
crc32: crc32.toString(),
}).then((res) => {
if (res.desynced) {
console.log('resynced');
if (_DEV_) console.log('resynced');
restoreGame(res.game!);
}
});
@@ -500,17 +500,6 @@ $gap: 4px;
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 {
padding: 32px;

View File

@@ -113,7 +113,7 @@ if (defaultStore.state.uploadFolder) {
function chooseUploadFolder() {
os.selectDriveFolder(false).then(async folder => {
defaultStore.set('uploadFolder', folder ? folder.id : null);
defaultStore.set('uploadFolder', folder[0] ? folder[0].id : null);
os.success();
if (defaultStore.state.uploadFolder) {
uploadFolder.value = await misskeyApi('drive/folders/show', {

View File

@@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
>
<template #item="{element}">
<button class="_button" :class="$style.emojisItem" @click="removeReaction(element, $event)">
<MkCustomEmoji v-if="element[0] === ':'" :name="element" :normal="true"/>
<MkCustomEmoji v-if="element[0] === ':'" :name="element" :normal="true" :fallbackToImage="true"/>
<MkEmoji v-else :emoji="element" :normal="true"/>
</button>
</template>
@@ -63,7 +63,7 @@ SPDX-License-Identifier: AGPL-3.0-only
>
<template #item="{element}">
<button class="_button" :class="$style.emojisItem" @click="removeEmoji(element, $event)">
<MkCustomEmoji v-if="element[0] === ':'" :name="element" :normal="true"/>
<MkCustomEmoji v-if="element[0] === ':'" :name="element" :normal="true" :fallbackToImage="true"/>
<MkEmoji v-else :emoji="element" :normal="true"/>
</button>
</template>
@@ -213,7 +213,7 @@ async function pickEmoji(itemsRef: Ref<string[]>, ev: MouseEvent) {
os.pickEmoji(getHTMLElement(ev), {
showPinned: false,
}).then(it => {
const emoji = it as string;
const emoji = it;
if (!itemsRef.value.includes(emoji)) {
itemsRef.value.push(emoji);
}

View File

@@ -56,6 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSwitch v-model="advancedMfm">{{ i18n.ts.enableAdvancedMfm }}</MkSwitch>
<MkSwitch v-if="advancedMfm" v-model="animatedMfm">{{ i18n.ts.enableAnimatedMfm }}</MkSwitch>
<MkSwitch v-if="advancedMfm" v-model="enableQuickAddMfmFunction">{{ i18n.ts.enableQuickAddMfmFunction }}</MkSwitch>
<MkSwitch v-model="showReactionsCount">{{ i18n.ts.showReactionsCount }}</MkSwitch>
<MkSwitch v-model="showGapBetweenNotesInTimeline">{{ i18n.ts.showGapBetweenNotesInTimeline }}</MkSwitch>
<MkSwitch v-model="loadRawImages">{{ i18n.ts.loadRawImages }}</MkSwitch>
<MkRadios v-model="reactionsDisplaySize">
@@ -281,6 +282,7 @@ const useBlurEffect = computed(defaultStore.makeGetterSetter('useBlurEffect'));
const showGapBetweenNotesInTimeline = computed(defaultStore.makeGetterSetter('showGapBetweenNotesInTimeline'));
const animatedMfm = computed(defaultStore.makeGetterSetter('animatedMfm'));
const advancedMfm = computed(defaultStore.makeGetterSetter('advancedMfm'));
const showReactionsCount = computed(defaultStore.makeGetterSetter('showReactionsCount'));
const enableQuickAddMfmFunction = computed(defaultStore.makeGetterSetter('enableQuickAddMfmFunction'));
const emojiStyle = computed(defaultStore.makeGetterSetter('emojiStyle'));
const disableDrawer = computed(defaultStore.makeGetterSetter('disableDrawer'));

View File

@@ -10,6 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<option value="following">{{ i18n.ts.following }}</option>
<option value="follower">{{ i18n.ts.followers }}</option>
<option value="mutualFollow">{{ i18n.ts.mutualFollow }}</option>
<option value="followingOrFollower">{{ i18n.ts.followingOrFollower }}</option>
<option value="list">{{ i18n.ts.userList }}</option>
<option value="never">{{ i18n.ts.none }}</option>
</MkSelect>

View File

@@ -16,6 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only
$i.notificationRecieveConfig[type]?.type === 'following' ? i18n.ts.following :
$i.notificationRecieveConfig[type]?.type === 'follower' ? i18n.ts.followers :
$i.notificationRecieveConfig[type]?.type === 'mutualFollow' ? i18n.ts.mutualFollow :
$i.notificationRecieveConfig[type]?.type === 'followingOrFollower' ? i18n.ts.followingOrFollower :
$i.notificationRecieveConfig[type]?.type === 'list' ? i18n.ts.userList :
i18n.ts.all
}}
@@ -34,6 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<FormSection>
<div class="_gaps_m">
<FormLink @click="testNotification">{{ i18n.ts._notification.sendTestNotification }}</FormLink>
<FormLink @click="flushNotification">{{ i18n.ts._notification.flushNotification }}</FormLink>
</div>
</FormSection>
<FormSection>
@@ -113,6 +115,17 @@ function testNotification(): void {
misskeyApi('notifications/test-notification');
}
async function flushNotification() {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.ts.resetAreYouSure,
});
if (canceled) return;
os.apiWithDialog('notifications/flush');
}
const headerActions = computed(() => []);
const headerTabs = computed(() => []);

View File

@@ -70,6 +70,7 @@ const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [
'animation',
'animatedMfm',
'advancedMfm',
'showReactionsCount',
'loadRawImages',
'imageNewTab',
'dataSaver',
@@ -203,6 +204,7 @@ async function saveNew(): Promise<void> {
const { canceled, result: name } = await os.inputText({
title: ts._preferencesBackups.inputName,
default: '',
});
if (canceled) return;
@@ -371,6 +373,7 @@ async function rename(id: string): Promise<void> {
const { canceled: cancel1, result: name } = await os.inputText({
title: ts._preferencesBackups.inputName,
default: '',
});
if (cancel1 || profiles.value[id].name === name) return;

View File

@@ -93,9 +93,11 @@ export class Autocomplete {
return;
}
const afterLastMfmParam = text.split(/\$\[[a-zA-Z]+/).pop();
const isMention = mentionIndex !== -1;
const isHashtag = hashtagIndex !== -1;
const isMfmParam = mfmParamIndex !== -1 && text.split(/\$\[[a-zA-Z]+/).pop()?.includes('.');
const isMfmParam = mfmParamIndex !== -1 && afterLastMfmParam?.includes('.') && !afterLastMfmParam?.includes(' ');
const isMfmTag = mfmTagIndex !== -1 && !isMfmParam;
const isEmoji = emojiIndex !== -1 && text.split(/:[a-z0-9_+\-]+:/).pop()!.includes(':');

View File

@@ -1,8 +1,12 @@
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 {
const roleIdsThatCanBeUsedThisEmojiAsReaction = emoji.roleIdsThatCanBeUsedThisEmojiAsReaction ?? [];
return !(emoji.localOnly && note.user.host !== me.host)
export function checkReactionPermissions(me: Misskey.entities.MeDetailed, note: Misskey.entities.Note, emoji: Misskey.entities.EmojiSimple | UnicodeEmojiDef | string): boolean {
if (typeof emoji === 'string') return true; // UnicodeEmojiDefにも無い絵文字であれば文字列で来る。Unicode絵文字であることには変わりないので常にリアクション可能とする;
if ('char' in emoji) return true; // UnicodeEmojiDefなら常にリアクション可能
const roleIdsThatCanBeUsedThisEmojiAsReaction = emoji.roleIdsThatCanBeUsedThisEmojiAsReaction ?? [];
return !(emoji.localOnly && note.user.host !== me.host)
&& !(emoji.isSensitive && (note.reactionAcceptance === 'nonSensitiveOnly' || note.reactionAcceptance === 'nonSensitiveOnlyForLocalLikeOnlyForRemote'))
&& (roleIdsThatCanBeUsedThisEmojiAsReaction.length === 0 || me.roles.some(role => roleIdsThatCanBeUsedThisEmojiAsReaction.includes(role.id)));
}

View File

@@ -2,14 +2,18 @@ import { unisonReload } from '@/scripts/unison-reload.js';
import * as os from '@/os.js';
import { miLocalStorage } from '@/local-storage.js';
import { fetchCustomEmojis } from '@/custom-emojis.js';
import { fetchInstance } from '@/instance.js';
export async function clearCache() {
os.waiting();
miLocalStorage.removeItem('instance');
miLocalStorage.removeItem('instanceCachedAt');
miLocalStorage.removeItem('locale');
miLocalStorage.removeItem('localeVersion');
miLocalStorage.removeItem('theme');
miLocalStorage.removeItem('emojis');
miLocalStorage.removeItem('lastEmojisFetchedAt');
await fetchInstance(true);
await fetchCustomEmojis(true);
unisonReload();
}

View File

@@ -20,6 +20,10 @@ export const emojilist: UnicodeEmojiDef[] = _emojilist.map(x => ({
category: unicodeEmojiCategories[x[2]],
}));
const unicodeEmojisMap = new Map<string, UnicodeEmojiDef>(
emojilist.map(x => [x.char, x]),
);
const _indexByChar = new Map<string, number>();
const _charGroupByCategory = new Map<string, string[]>();
for (let i = 0; i < emojilist.length; i++) {
@@ -35,16 +39,29 @@ for (let i = 0; i < emojilist.length; i++) {
export const emojiCharByCategory = _charGroupByCategory;
export function getEmojiName(char: string): string | null {
export function getUnicodeEmoji(char: string): UnicodeEmojiDef | string {
// Colorize it because emojilist.json assumes that
const idx = _indexByChar.get(colorizeEmoji(char));
if (idx == null) {
return null;
return unicodeEmojisMap.get(colorizeEmoji(char))
// カラースタイル絵文字がjsonに無い場合はテキストスタイル絵文字にフォールバックする
?? unicodeEmojisMap.get(char)
// それでも見つからない場合はそのまま返す絵文字情報がjsonに無い場合、このフォールバックが無いとレンダリングに失敗する
?? char;
}
export function getEmojiName(char: string): string {
// Colorize it because emojilist.json assumes that
const idx = _indexByChar.get(colorizeEmoji(char)) ?? _indexByChar.get(char);
if (idx === undefined) {
// 絵文字情報がjsonに無い場合は名前の取得が出来ないのでそのまま返すしか無い
return char;
} else {
return emojilist[idx].name;
}
}
/**
* テキストスタイル絵文字U+260Eなどの1文字で表現される絵文字をカラースタイル絵文字に変換しますVS16:U+FE0Fを付与
*/
export function colorizeEmoji(char: string) {
return char.length === 1 ? `${char}\uFE0F` : char;
}

View File

@@ -12,29 +12,37 @@ export type FormItem = {
label?: string;
type: 'string';
default: string | null;
description?: string;
required?: boolean;
hidden?: boolean;
multiline?: boolean;
treatAsMfm?: boolean;
} | {
label?: string;
type: 'number';
default: number | null;
description?: string;
required?: boolean;
hidden?: boolean;
step?: number;
} | {
label?: string;
type: 'boolean';
default: boolean | null;
description?: string;
hidden?: boolean;
} | {
label?: string;
type: 'enum';
default: string | null;
required?: boolean;
hidden?: boolean;
enum: EnumItem[];
} | {
label?: string;
type: 'radio';
default: unknown | null;
required?: boolean;
hidden?: boolean;
options: {
label: string;
@@ -44,9 +52,12 @@ export type FormItem = {
label?: string;
type: 'range';
default: number | null;
step: number;
description?: string;
required?: boolean;
step?: number;
min: number;
max: number;
textConverter?: (value: number) => string;
} | {
label?: string;
type: 'object';
@@ -57,6 +68,10 @@ export type FormItem = {
type: 'array';
default: unknown[] | null;
hidden: boolean;
} | {
type: 'button';
content?: string;
action: (ev: MouseEvent, v: any) => void;
};
export type Form = Record<string, FormItem>;

View File

@@ -0,0 +1,106 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
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) {
const sound = defaultStore.state[`sound_${operationType}`];
if (sound.type == null || !canPlay) return;
if (sound.type == null || !canPlay || ('userActivation' in navigator && !navigator.userActivation.hasBeenActive)) return;
canPlay = false;
playMisskeySfxFile(sound).finally(() => {

View File

@@ -35,6 +35,7 @@ export function useNoteCapture(props: {
const currentCount = (note.value.reactions || {})[reaction] || 0;
note.value.reactions[reaction] = currentCount + 1;
note.value.reactionCount += 1;
if ($i && (body.userId === $i.id)) {
note.value.myReaction = reaction;
@@ -49,6 +50,7 @@ export function useNoteCapture(props: {
const currentCount = (note.value.reactions || {})[reaction] || 0;
note.value.reactions[reaction] = Math.max(0, currentCount - 1);
note.value.reactionCount = Math.max(0, note.value.reactionCount - 1);
if (note.value.reactions[reaction] === 0) delete note.value.reactions[reaction];
if ($i && (body.userId === $i.id)) {

View File

@@ -227,6 +227,10 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'device',
default: true,
},
showReactionsCount: {
where: 'device',
default: false,
},
enableQuickAddMfmFunction: {
where: 'device',
default: false,
@@ -431,10 +435,10 @@ export const defaultStore = markRaw(new Storage('base', {
sfxVolume: 1,
},
},
hemisphere: {
hemisphere: {
where: 'device',
default: hemisphere as 'N' | 'S',
},
},
enableHorizontalSwipe: {
where: 'device',
default: true,

View File

@@ -22,6 +22,13 @@
}
//--ad: rgb(255 169 0 / 10%);
--eventFollow: #36aed2;
--eventRenote: #36d298;
--eventReply: #007aff;
--eventReactionHeart: #dd2e44;
--eventReaction: #e99a0b;
--eventAchievement: #cb9a11;
--eventOther: #88a6b7;
}
::selection {
@@ -417,6 +424,39 @@ rt {
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: opacity 0.5s, transform 0.5s !important;
}

View File

@@ -117,6 +117,7 @@ import XMentionsColumn from '@/ui/deck/mentions-column.vue';
import XDirectColumn from '@/ui/deck/direct-column.vue';
import XRoleTimelineColumn from '@/ui/deck/role-timeline-column.vue';
import { mainRouter } from '@/router/main.js';
import { MenuItem } from '@/types/menu.js';
const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue'));
const XAnnouncements = defineAsyncComponent(() => import('@/ui/_common_/announcements.vue'));
@@ -221,21 +222,19 @@ document.documentElement.style.scrollBehavior = 'auto';
loadDeck();
function changeProfile(ev: MouseEvent) {
const items = ref([{
let items: MenuItem[] = [{
text: deckStore.state.profile,
active: true.valueOf,
}]);
active: true,
action: () => {},
}];
getProfiles().then(profiles => {
items.value = [{
text: deckStore.state.profile,
active: true.valueOf,
}, ...(profiles.filter(k => k !== deckStore.state.profile).map(k => ({
items.push(...(profiles.filter(k => k !== deckStore.state.profile).map(k => ({
text: k,
action: () => {
deckStore.set('profile', k);
unisonReload();
},
}))), { type: 'divider' }, {
}))), { type: 'divider' as const }, {
text: i18n.ts._deck.newProfile,
icon: 'ti ti-plus',
action: async () => {
@@ -248,9 +247,10 @@ function changeProfile(ev: MouseEvent) {
deckStore.set('profile', name);
unisonReload();
},
}];
});
}).then(() => {
os.popupMenu(items, ev.currentTarget ?? ev.target);
});
os.popupMenu(items, ev.currentTarget ?? ev.target);
}
async function deleteProfile() {

View File

@@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template v-if="column.channelId">
<div style="padding: 8px; text-align: center;">
<MkButton primary gradate rounded inline @click="post"><i class="ti ti-pencil"></i></MkButton>
<MkButton primary gradate rounded inline small @click="post"><i class="ti ti-pencil"></i></MkButton>
</div>
<MkTimeline ref="timeline" src="channel" :channel="column.channelId"/>
</template>

View File

@@ -93,10 +93,10 @@ const fetch = () => {
const choose = () => {
os.selectDriveFolder(false).then(folder => {
if (folder == null) {
if (folder[0] == null) {
return;
}
widgetProps.folderId = folder.id;
widgetProps.folderId = folder[0].id;
save();
fetch();
});

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

@@ -9,6 +9,7 @@ import { onScrollBottom, onScrollTop } from '@/scripts/scroll.js';
describe('Scroll', () => {
describe('onScrollTop', () => {
/* 動作しない(happy-domのバグ)
test('Initial onScrollTop callback for connected elements', () => {
const { document } = new Window();
const div = document.createElement('div');
@@ -21,6 +22,7 @@ describe('Scroll', () => {
assert.ok(called);
});
*/
test('No onScrollTop callback for disconnected elements', () => {
const { document } = new Window();
@@ -35,11 +37,11 @@ describe('Scroll', () => {
});
describe('onScrollBottom', () => {
/* 動作しない(happy-domのバグ)
test('Initial onScrollBottom callback for connected elements', () => {
const { document } = new Window();
const div = document.createElement('div');
assert.strictEqual(div.scrollTop, 0);
(div as any).scrollHeight = 100; // happy-dom has no scrollHeight
document.body.append(div);
@@ -48,12 +50,12 @@ describe('Scroll', () => {
assert.ok(called);
});
*/
test('No onScrollBottom callback for disconnected elements', () => {
const { document } = new Window();
const div = document.createElement('div');
assert.strictEqual(div.scrollTop, 0);
(div as any).scrollHeight = 100; // happy-dom has no scrollHeight
let called = false;
onScrollBottom(div as any as HTMLElement, () => called = true);

View File

@@ -48,6 +48,9 @@ const devConfig = {
},
'/url': httpUrl,
'/proxy': httpUrl,
'/_info_card_': httpUrl,
'/bios': httpUrl,
'/cli': httpUrl,
},
},
build: {