diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b43319614..e6dd704c5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,5 @@ + + + + + diff --git a/packages/frontend/src/const.ts b/packages/frontend/src/const.ts index ea07204456..38d0d85eef 100644 --- a/packages/frontend/src/const.ts +++ b/packages/frontend/src/const.ts @@ -109,3 +109,27 @@ export const DEFAULT_NOT_FOUND_IMAGE_URL = 'https://xn--931a.moe/assets/not-foun export const DEFAULT_INFO_IMAGE_URL = 'https://xn--931a.moe/assets/info.jpg'; export const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'scale', 'position', 'fg', 'bg', 'border', 'font', 'blur', 'rainbow', 'sparkle', 'rotate', 'ruby', 'unixtime']; +export const MFM_PARAMS: Record = { + tada: ['speed=', 'delay='], + jelly: ['speed=', 'delay='], + twitch: ['speed=', 'delay='], + shake: ['speed=', 'delay='], + spin: ['speed=', 'delay=', 'left', 'alternate', 'x', 'y'], + jump: ['speed=', 'delay='], + bounce: ['speed=', 'delay='], + flip: ['h', 'v'], + x2: [], + x3: [], + x4: [], + scale: ['x=', 'y='], + position: ['x=', 'y='], + fg: ['color='], + bg: ['color='], + border: ['width=', 'style=', 'color=', 'radius=', 'noclip'], + font: ['serif', 'monospace', 'cursive', 'fantasy', 'emoji', 'math'], + blur: [], + rainbow: ['speed=', 'delay='], + rotate: ['deg='], + ruby: [], + unixtime: [], +}; diff --git a/packages/frontend/src/global/router/definition.ts b/packages/frontend/src/global/router/definition.ts index 42fabb8266..e7f0ce035f 100644 --- a/packages/frontend/src/global/router/definition.ts +++ b/packages/frontend/src/global/router/definition.ts @@ -333,7 +333,12 @@ const routes: RouteDef[] = [{ component: page(() => import('@/pages/registry.vue')), }, { path: '/install-extentions', - component: page(() => import('@/pages/install-extentions.vue')), + // Note: This path is kept for compatibility. It may be deleted. + component: page(() => import('@/pages/install-extensions.vue')), + loginRequired: true, +}, { + path: '/install-extensions', + component: page(() => import('@/pages/install-extensions.vue')), loginRequired: true, }, { path: '/admin/user/:userId', diff --git a/packages/frontend/src/i18n.ts b/packages/frontend/src/i18n.ts index 858db74dac..c5c4ccf820 100644 --- a/packages/frontend/src/i18n.ts +++ b/packages/frontend/src/i18n.ts @@ -10,6 +10,7 @@ import { I18n } from '@/scripts/i18n.js'; export const i18n = markRaw(new I18n(locale)); -export function updateI18n(newLocale) { - i18n.ts = newLocale; +export function updateI18n(newLocale: Locale) { + // @ts-expect-error -- private field + i18n.locale = newLocale; } diff --git a/packages/frontend/src/pages/about.vue b/packages/frontend/src/pages/about.vue index f402b26ad8..4ba1b6da76 100644 --- a/packages/frontend/src/pages/about.vue +++ b/packages/frontend/src/pages/about.vue @@ -6,98 +6,100 @@ SPDX-License-Identifier: AGPL-3.0-only @@ -114,6 +116,7 @@ import FormSplit from '@/components/form/split.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkKeyValue from '@/components/MkKeyValue.vue'; import MkInstanceStats from '@/components/MkInstanceStats.vue'; +import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import { misskeyApi } from '@/scripts/misskey-api.js'; import number from '@/filters/number.js'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/pages/admin/security.vue b/packages/frontend/src/pages/admin/security.vue index a691d8ea1e..d5d1258733 100644 --- a/packages/frontend/src/pages/admin/security.vue +++ b/packages/frontend/src/pages/admin/security.vue @@ -71,27 +71,28 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts.activeEmailValidationDescription }} - + - + - + - + - + - + + {{ i18n.ts.save }}
@@ -192,7 +193,10 @@ async function init() { enableActiveEmailValidation.value = meta.enableActiveEmailValidation; enableVerifymailApi.value = meta.enableVerifymailApi; verifymailAuthKey.value = meta.verifymailAuthKey; - bannedEmailDomains.value = meta.bannedEmailDomains.join('\n'); + enableTruemailApi.value = meta.enableTruemailApi; + truemailInstance.value = meta.truemailInstance; + truemailAuthKey.value = meta.truemailAuthKey; + bannedEmailDomains.value = meta.bannedEmailDomains?.join('\n') || ""; } function save() { diff --git a/packages/frontend/src/pages/announcements.vue b/packages/frontend/src/pages/announcements.vue index 5632bf7caf..c31c6d0903 100644 --- a/packages/frontend/src/pages/announcements.vue +++ b/packages/frontend/src/pages/announcements.vue @@ -7,34 +7,36 @@ SPDX-License-Identifier: AGPL-3.0-only -
- {{ i18n.ts.youHaveUnreadAnnouncements }} - -
-
{{ i18n.ts.forYou }}
-
- 🆕 - - - - - - - {{ announcement.title }} -
-
- - -
- + +
+ {{ i18n.ts.youHaveUnreadAnnouncements }} + +
+
{{ i18n.ts.forYou }}
+
+ 🆕 + + + + + + + {{ announcement.title }}
-
-
- {{ i18n.ts.gotIt }} -
-
-
-
+
+ + +
+ +
+
+
+ {{ i18n.ts.gotIt }} +
+ + + +
@@ -44,6 +46,7 @@ import { ref, computed } from 'vue'; import MkPagination from '@/components/MkPagination.vue'; import MkButton from '@/components/MkButton.vue'; import MkInfo from '@/components/MkInfo.vue'; +import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue index 971eca8cae..4cdf2eea7d 100644 --- a/packages/frontend/src/pages/channel.vue +++ b/packages/frontend/src/pages/channel.vue @@ -7,53 +7,55 @@ SPDX-License-Identifier: AGPL-3.0-only -
-
- - - -
-
-
-
+ +
+
+ + + +
+
+
+
+
+
{{ i18n.ts.sensitive }}
+
+
+
+
-
{{ i18n.ts.sensitive }}
-
-
- + + + +
+ +
+
+
+
+ {{ i18n.ts.thisChannelArchived }} + + + + + +
+
+ +
+
+
+
+ + + + {{ i18n.ts.search }} +
+
- - - -
- -
-
-
-
- {{ i18n.ts.thisChannelArchived }} - - - - - -
-
- -
-
-
-
- - - - {{ i18n.ts.search }} -
- -
-
+
@@ -58,6 +60,7 @@ import MkInput from '@/components/MkInput.vue'; import MkRadios from '@/components/MkRadios.vue'; import MkButton from '@/components/MkButton.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; +import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { i18n } from '@/i18n.js'; import { useRouter } from '@/global/router/supplier.js'; diff --git a/packages/frontend/src/pages/drive.file.vue b/packages/frontend/src/pages/drive.file.vue index 2c1e5d20a7..6a9e907963 100644 --- a/packages/frontend/src/pages/drive.file.vue +++ b/packages/frontend/src/pages/drive.file.vue @@ -9,13 +9,15 @@ SPDX-License-Identifier: AGPL-3.0-only - - - + + + + - - - + + + + @@ -23,6 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, ref, defineAsyncComponent } from 'vue'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; +import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; const props = defineProps<{ fileId: string; diff --git a/packages/frontend/src/pages/explore.vue b/packages/frontend/src/pages/explore.vue index f068de8880..1b80014366 100644 --- a/packages/frontend/src/pages/explore.vue +++ b/packages/frontend/src/pages/explore.vue @@ -6,17 +6,17 @@ SPDX-License-Identifier: AGPL-3.0-only @@ -26,6 +26,7 @@ import XFeatured from './explore.featured.vue'; import XUsers from './explore.users.vue'; import XRoles from './explore.roles.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; +import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/pages/flash/flash-index.vue b/packages/frontend/src/pages/flash/flash-index.vue index 7852018894..53510ea232 100644 --- a/packages/frontend/src/pages/flash/flash-index.vue +++ b/packages/frontend/src/pages/flash/flash-index.vue @@ -7,32 +7,34 @@ SPDX-License-Identifier: AGPL-3.0-only -
- -
- -
-
-
- -
-
- - + +
+
-
-
- -
- +
+
+ + +
+ +
+
- -
+
+ +
+ +
+ +
+
+
+ @@ -42,6 +44,7 @@ import { computed, ref } from 'vue'; import MkFlashPreview from '@/components/MkFlashPreview.vue'; import MkPagination from '@/components/MkPagination.vue'; import MkButton from '@/components/MkButton.vue'; +import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { useRouter } from '@/global/router/supplier.js'; diff --git a/packages/frontend/src/pages/gallery/index.vue b/packages/frontend/src/pages/gallery/index.vue index 0198ab9700..9749888fe9 100644 --- a/packages/frontend/src/pages/gallery/index.vue +++ b/packages/frontend/src/pages/gallery/index.vue @@ -7,8 +7,8 @@ SPDX-License-Identifier: AGPL-3.0-only -
-
+ +
@@ -26,14 +26,14 @@ SPDX-License-Identifier: AGPL-3.0-only
-
+
-
+
{{ i18n.ts.postToGallery }}
@@ -41,7 +41,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-
+ @@ -51,6 +51,7 @@ import { watch, ref, computed } from 'vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkPagination from '@/components/MkPagination.vue'; import MkGalleryPostPreview from '@/components/MkGalleryPostPreview.vue'; +import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { i18n } from '@/i18n.js'; import { useRouter } from '@/global/router/supplier.js'; diff --git a/packages/frontend/src/pages/install-extentions.vue b/packages/frontend/src/pages/install-extensions.vue similarity index 100% rename from packages/frontend/src/pages/install-extentions.vue rename to packages/frontend/src/pages/install-extensions.vue diff --git a/packages/frontend/src/pages/instance-info.vue b/packages/frontend/src/pages/instance-info.vue index c8a0eeeeaa..4211dc0d87 100644 --- a/packages/frontend/src/pages/instance-info.vue +++ b/packages/frontend/src/pages/instance-info.vue @@ -7,111 +7,113 @@ SPDX-License-Identifier: AGPL-3.0-only -
-
- - {{ instance.name || `(${i18n.ts.unknown})` }} -
-
- - - - - - - - - - - - -
- - - - - - - -
- {{ i18n.ts.stopActivityDelivery }} - {{ i18n.ts.blockThisInstance }} - {{ i18n.ts.silenceThisInstance }} - Refresh metadata + +
+
+ + {{ instance.name || `(${i18n.ts.unknown})` }}
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - host-meta - host-meta.json - nodeinfo - robots.txt - manifest.json - -
-
-
-
- - - - - - - - - - - - - +
+ + + + + + + + + + + +
-
-
{{ i18n.t('recentNHours', { n: 90 }) }}
- -
{{ i18n.t('recentNDays', { n: 90 }) }}
- + + + + + + + +
+ {{ i18n.ts.stopActivityDelivery }} + {{ i18n.ts.blockThisInstance }} + {{ i18n.ts.silenceThisInstance }} + Refresh metadata +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + host-meta + host-meta.json + nodeinfo + robots.txt + manifest.json + +
+
+
+
+ + + + + + + + + + + + + +
+
+
{{ i18n.t('recentNHours', { n: 90 }) }}
+ +
{{ i18n.t('recentNDays', { n: 90 }) }}
+ +
-
-
- - - - - -
-
- - -
+
+ + + + + +
+
+ + +
+ @@ -136,6 +138,7 @@ import { definePageMetadata } from '@/scripts/page-metadata.js'; import { i18n } from '@/i18n.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; 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'; @@ -144,6 +147,7 @@ const props = defineProps<{ }>(); const tab = ref('overview'); + const chartSrc = ref('instance-requests'); const meta = ref(null); const instance = ref(null); diff --git a/packages/frontend/src/pages/my-clips/index.vue b/packages/frontend/src/pages/my-clips/index.vue index 850222708e..468e46838b 100644 --- a/packages/frontend/src/pages/my-clips/index.vue +++ b/packages/frontend/src/pages/my-clips/index.vue @@ -7,20 +7,22 @@ SPDX-License-Identifier: AGPL-3.0-only -
- {{ i18n.ts.add }} + +
+ {{ i18n.ts.add }} - - + + + + + +
+
+ - -
-
- - - -
+
+
@@ -36,6 +38,7 @@ import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { clipsCache } from '@/cache.js'; +import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; const pagination = { endpoint: 'clips/list' as const, @@ -44,6 +47,7 @@ const pagination = { }; const tab = ref('my'); + const favorites = ref(null); const pagingComponent = shallowRef>(); diff --git a/packages/frontend/src/pages/notifications.vue b/packages/frontend/src/pages/notifications.vue index d57bda41b5..8913a89adb 100644 --- a/packages/frontend/src/pages/notifications.vue +++ b/packages/frontend/src/pages/notifications.vue @@ -7,15 +7,17 @@ SPDX-License-Identifier: AGPL-3.0-only -
- -
-
- -
-
- -
+ +
+ +
+
+ +
+
+ +
+
@@ -24,6 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, ref } from 'vue'; import XNotifications from '@/components/MkNotifications.vue'; import MkNotes from '@/components/MkNotes.vue'; +import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; @@ -96,3 +99,10 @@ definePageMetadata(computed(() => ({ icon: 'ti ti-bell', }))); + + diff --git a/packages/frontend/src/pages/pages.vue b/packages/frontend/src/pages/pages.vue index 22ab9ced09..8b57b1af9f 100644 --- a/packages/frontend/src/pages/pages.vue +++ b/packages/frontend/src/pages/pages.vue @@ -7,30 +7,32 @@ SPDX-License-Identifier: AGPL-3.0-only -
- -
- -
-
-
+ +
+ +
+ +
+
+
-
- - -
- -
-
-
+
+ + +
+ +
+
+
-
- -
- -
-
-
+
+ +
+ +
+
+
+
@@ -40,6 +42,7 @@ import { computed, ref } from 'vue'; import MkPagePreview from '@/components/MkPagePreview.vue'; import MkPagination from '@/components/MkPagination.vue'; import MkButton from '@/components/MkButton.vue'; +import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { useRouter } from '@/global/router/supplier.js'; diff --git a/packages/frontend/src/pages/search.vue b/packages/frontend/src/pages/search.vue index 9d5e5697ce..b68de805cf 100644 --- a/packages/frontend/src/pages/search.vue +++ b/packages/frontend/src/pages/search.vue @@ -7,18 +7,20 @@ SPDX-License-Identifier: AGPL-3.0-only - -
- -
-
- {{ i18n.ts.notesSearchNotAvailable }} -
-
+ + +
+ +
+
+ {{ i18n.ts.notesSearchNotAvailable }} +
+
- - - + + + +
@@ -29,6 +31,7 @@ import { definePageMetadata } from '@/scripts/page-metadata.js'; import { $i } from '@/account.js'; import { instance } from '@/instance.js'; import MkInfo from '@/components/MkInfo.vue'; +import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; const XNote = defineAsyncComponent(() => import('./search.note.vue')); const XUser = defineAsyncComponent(() => import('./search.user.vue')); diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue index 607aaec521..e52a5ee04f 100644 --- a/packages/frontend/src/pages/settings/general.vue +++ b/packages/frontend/src/pages/settings/general.vue @@ -155,6 +155,7 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.enableInfiniteScroll }} {{ i18n.ts.keepScreenOn }} {{ i18n.ts.disableStreamingTimeline }} + {{ i18n.ts.enableHorizontalSwipe }}
@@ -296,6 +297,7 @@ const keepScreenOn = computed(defaultStore.makeGetterSetter('keepScreenOn')); const disableStreamingTimeline = computed(defaultStore.makeGetterSetter('disableStreamingTimeline')); const useGroupedNotifications = computed(defaultStore.makeGetterSetter('useGroupedNotifications')); const enableSeasonalScreenEffect = computed(defaultStore.makeGetterSetter('enableSeasonalScreenEffect')); +const enableHorizontalSwipe = computed(defaultStore.makeGetterSetter('enableHorizontalSwipe')); watch(lang, () => { miLocalStorage.setItem('lang', lang.value as string); diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue index 6fe8963f51..666a9968b2 100644 --- a/packages/frontend/src/pages/timeline.vue +++ b/packages/frontend/src/pages/timeline.vue @@ -7,27 +7,28 @@ SPDX-License-Identifier: AGPL-3.0-only -
- - {{ i18n.ts._timelineDescription[src] }} - - - -
-
- + +
+ + {{ i18n.ts._timelineDescription[src] }} + + +
+
+ +
-
+ @@ -38,6 +39,7 @@ import type { Tab } from '@/components/global/MkPageHeader.tabs.vue'; import MkTimeline from '@/components/MkTimeline.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkPostForm from '@/components/MkPostForm.vue'; +import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import { scroll } from '@/scripts/scroll.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; @@ -69,7 +71,9 @@ const withRenotes = ref(true); const withReplies = ref($i ? defaultStore.state.tlWithReplies : false); const onlyFiles = ref(false); -watch(src, () => queue.value = 0); +watch(src, () => { + queue.value = 0; +}); watch(withReplies, (x) => { if ($i) defaultStore.set('tlWithReplies', x); diff --git a/packages/frontend/src/pages/user/index.vue b/packages/frontend/src/pages/user/index.vue index 95869e7b8c..603f1bef33 100644 --- a/packages/frontend/src/pages/user/index.vue +++ b/packages/frontend/src/pages/user/index.vue @@ -8,19 +8,21 @@ SPDX-License-Identifier: AGPL-3.0-only
- - - - - - - - - - - - - + + + + + + + + + + + + + + +
@@ -36,6 +38,7 @@ import { misskeyApi } from '@/scripts/misskey-api.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { i18n } from '@/i18n.js'; import { $i } from '@/account.js'; +import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; const XHome = defineAsyncComponent(() => import('./home.vue')); const XTimeline = defineAsyncComponent(() => import('./index.timeline.vue')); @@ -57,6 +60,7 @@ const props = withDefaults(defineProps<{ }); const tab = ref(props.page); + const user = ref(null); const error = ref(null); diff --git a/packages/frontend/src/scripts/autocomplete.ts b/packages/frontend/src/scripts/autocomplete.ts index 2a9a42ace5..b0c36cb927 100644 --- a/packages/frontend/src/scripts/autocomplete.ts +++ b/packages/frontend/src/scripts/autocomplete.ts @@ -8,13 +8,13 @@ import getCaretCoordinates from 'textarea-caret'; import { toASCII } from 'punycode/'; import { popup } from '@/os.js'; -export type SuggestionType = 'user' | 'hashtag' | 'emoji' | 'mfmTag'; +export type SuggestionType = 'user' | 'hashtag' | 'emoji' | 'mfmTag' | 'mfmParam'; export class Autocomplete { private suggestion: { x: Ref; y: Ref; - q: Ref; + q: Ref; close: () => void; } | null; private textarea: HTMLInputElement | HTMLTextAreaElement; @@ -49,7 +49,7 @@ export class Autocomplete { this.textarea = textarea; this.textRef = textRef; this.opening = false; - this.onlyType = onlyType ?? ['user', 'hashtag', 'emoji', 'mfmTag']; + this.onlyType = onlyType ?? ['user', 'hashtag', 'emoji', 'mfmTag', 'mfmParam']; this.attach(); } @@ -80,6 +80,7 @@ export class Autocomplete { const hashtagIndex = text.lastIndexOf('#'); const emojiIndex = text.lastIndexOf(':'); const mfmTagIndex = text.lastIndexOf('$'); + const mfmParamIndex = text.lastIndexOf('.'); const max = Math.max( mentionIndex, @@ -94,7 +95,8 @@ export class Autocomplete { const isMention = mentionIndex !== -1; const isHashtag = hashtagIndex !== -1; - const isMfmTag = mfmTagIndex !== -1; + const isMfmParam = mfmParamIndex !== -1 && text.split(/\$\[[a-zA-Z]+/).pop()?.includes('.'); + const isMfmTag = mfmTagIndex !== -1 && !isMfmParam; const isEmoji = emojiIndex !== -1 && text.split(/:[a-z0-9_+\-]+:/).pop()!.includes(':'); let opened = false; @@ -134,6 +136,17 @@ export class Autocomplete { } } + if (isMfmParam && !opened && this.onlyType.includes('mfmParam')) { + const mfmParam = text.substring(mfmParamIndex + 1); + if (!mfmParam.includes(' ')) { + this.open('mfmParam', { + tag: text.substring(mfmTagIndex + 2, mfmParamIndex), + params: mfmParam.split(','), + }); + opened = true; + } + } + if (!opened) { this.close(); } @@ -142,7 +155,7 @@ export class Autocomplete { /** * サジェストを提示します。 */ - private async open(type: string, q: string | null) { + private async open(type: string, q: any) { if (type !== this.currentType) { this.close(); } @@ -280,6 +293,22 @@ export class Autocomplete { const pos = trimmedBefore.length + (value.length + 3); this.textarea.setSelectionRange(pos, pos); }); + } else if (type === 'mfmParam') { + const source = this.text; + + const before = source.substring(0, caret); + const trimmedBefore = before.substring(0, before.lastIndexOf('.')); + const after = source.substring(caret); + + // 挿入 + this.text = `${trimmedBefore}.${value}${after}`; + + // キャレットを戻す + nextTick(() => { + this.textarea.focus(); + const pos = trimmedBefore.length + (value.length + 1); + this.textarea.setSelectionRange(pos, pos); + }); } } } diff --git a/packages/frontend/src/scripts/i18n.ts b/packages/frontend/src/scripts/i18n.ts index 8e5f17f38a..3366f3eac3 100644 --- a/packages/frontend/src/scripts/i18n.ts +++ b/packages/frontend/src/scripts/i18n.ts @@ -2,33 +2,114 @@ * SPDX-FileCopyrightText: syuilo and other misskey contributors * SPDX-License-Identifier: AGPL-3.0-only */ +import type { ILocale, ParameterizedString } from '../../../../locales/index.js'; -export class I18n> { - public ts: T; +type FlattenKeys = keyof { + [K in keyof T as T[K] extends ILocale + ? FlattenKeys extends infer C extends string + ? `${K & string}.${C}` + : never + : T[K] extends TPrediction + ? K + : never]: T[K]; +}; - constructor(locale: T) { - this.ts = locale; +type ParametersOf>> = T extends ILocale + ? TKey extends `${infer K}.${infer C}` + // @ts-expect-error -- C は明らかに FlattenKeys> になるが、型システムはここでは TKey がドット区切りであることのコンテキストを持たないので、型システムに合法にて示すことはできない。 + ? ParametersOf + : TKey extends keyof T + ? T[TKey] extends ParameterizedString + ? P + : never + : never + : never; +type Ts = { + readonly [K in keyof T as T[K] extends ParameterizedString ? never : K]: T[K] extends ILocale ? Ts : string; +}; + +export class I18n { + constructor(private locale: T) { //#region BIND this.t = this.t.bind(this); //#endregion } - // string にしているのは、ドット区切りでのパス指定を許可するため - // なるべくこのメソッド使うよりもlocale直接参照の方がvueのキャッシュ効いてパフォーマンスが良いかも - public t(key: string, args?: Record): string { - try { - let str = key.split('.').reduce((o, i) => o[i], this.ts) as unknown as string; + public get ts(): Ts { + if (_DEV_) { + class Handler implements ProxyHandler { + get(target: TTarget, p: string | symbol): unknown { + const value = target[p as keyof TTarget]; - if (args) { - for (const [k, v] of Object.entries(args)) { - str = str.replace(`{${k}}`, v.toString()); + if (typeof value === 'object') { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- 実際には null がくることはないので。 + return new Proxy(value!, new Handler()); + } + + if (typeof value === 'string') { + const parameters = Array.from(value.matchAll(/\{(\w+)\}/g), ([, parameter]) => parameter); + + if (parameters.length) { + console.error(`Missing locale parameters: ${parameters.join(', ')} at ${String(p)}`); + } + + return value; + } + + console.error(`Unexpected locale key: ${String(p)}`); + + return p; } } - return str; - } catch (err) { - console.warn(`missing localization '${key}'`); - return key; + + return new Proxy(this.locale, new Handler()) as Ts; } + + return this.locale as Ts; + } + + /** + * @deprecated なるべくこのメソッド使うよりも locale 直接参照の方が vue のキャッシュ効いてパフォーマンスが良いかも + */ + public t>(key: TKey): string; + public t>>(key: TKey, args: { readonly [_ in ParametersOf]: string | number }): string; + public t(key: string, args?: { readonly [_: string]: string | number }) { + let str: string | ParameterizedString | ILocale = this.locale; + + for (const k of key.split('.')) { + str = str[k]; + + if (_DEV_) { + if (typeof str === 'undefined') { + console.error(`Unexpected locale key: ${key}`); + return key; + } + } + } + + if (args) { + if (_DEV_) { + const missing = Array.from((str as string).matchAll(/\{(\w+)\}/g), ([, parameter]) => parameter).filter(parameter => !Object.hasOwn(args, parameter)); + + if (missing.length) { + console.error(`Missing locale parameters: ${missing.join(', ')} at ${key}`); + } + } + + for (const [k, v] of Object.entries(args)) { + const search = `{${k}}`; + + if (_DEV_) { + if (!(str as string).includes(search)) { + console.error(`Unexpected locale parameter: ${k} at ${key}`); + } + } + + str = (str as string).replace(search, v.toString()); + } + } + + return str; } } diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index e3a85377d8..21b796caa1 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -427,6 +427,10 @@ export const defaultStore = markRaw(new Storage('base', { sfxVolume: 1, }, }, + enableHorizontalSwipe: { + where: 'device', + default: true, + }, sound_masterVolume: { where: 'device',