summaryrefslogtreecommitdiff
path: root/packages/frontend/src
diff options
context:
space:
mode:
authorかっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>2024-06-01 11:27:03 +0900
committerGitHub <noreply@github.com>2024-06-01 11:27:03 +0900
commitfce66b85b603caac79e1bfa87b5f4621b1ba9d4e (patch)
treed22952ee3f8e30057977a99a33823f4d52990fbc /packages/frontend/src
parentMerge pull request #13493 from misskey-dev/develop (diff)
parentfix(backend): use insertOne insteadof insert/findOneOrFail combination (#13908) (diff)
downloadsharkey-fce66b85b603caac79e1bfa87b5f4621b1ba9d4e.tar.gz
sharkey-fce66b85b603caac79e1bfa87b5f4621b1ba9d4e.tar.bz2
sharkey-fce66b85b603caac79e1bfa87b5f4621b1ba9d4e.zip
Merge pull request #13917 from misskey-dev/develop
Release 2024.5.0 (master)
Diffstat (limited to 'packages/frontend/src')
-rw-r--r--packages/frontend/src/_dev_boot_.ts2
-rw-r--r--packages/frontend/src/boot/common.ts3
-rw-r--r--packages/frontend/src/boot/main-boot.ts72
-rw-r--r--packages/frontend/src/cache.ts1
-rw-r--r--packages/frontend/src/components/MkAbuseReport.vue2
-rw-r--r--packages/frontend/src/components/MkAccountMoved.stories.impl.ts15
-rw-r--r--packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts20
-rw-r--r--packages/frontend/src/components/MkAsUi.vue4
-rw-r--r--packages/frontend/src/components/MkButton.vue2
-rw-r--r--packages/frontend/src/components/MkClipPreview.vue52
-rw-r--r--packages/frontend/src/components/MkCode.core.vue10
-rw-r--r--packages/frontend/src/components/MkCode.vue2
-rw-r--r--packages/frontend/src/components/MkContextMenu.vue8
-rw-r--r--packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue108
-rw-r--r--packages/frontend/src/components/MkDialog.vue2
-rw-r--r--packages/frontend/src/components/MkFeaturedPhotos.vue12
-rw-r--r--packages/frontend/src/components/MkFollowButton.vue12
-rw-r--r--packages/frontend/src/components/MkFormDialog.file.vue71
-rw-r--r--packages/frontend/src/components/MkFormDialog.vue12
-rw-r--r--packages/frontend/src/components/MkInput.vue2
-rw-r--r--packages/frontend/src/components/MkLink.vue22
-rw-r--r--packages/frontend/src/components/MkMediaAudio.vue127
-rw-r--r--packages/frontend/src/components/MkMediaImage.vue9
-rw-r--r--packages/frontend/src/components/MkMediaVideo.vue146
-rw-r--r--packages/frontend/src/components/MkMention.vue4
-rw-r--r--packages/frontend/src/components/MkMenu.vue91
-rw-r--r--packages/frontend/src/components/MkModal.vue29
-rw-r--r--packages/frontend/src/components/MkNote.vue65
-rw-r--r--packages/frontend/src/components/MkNoteDetailed.vue74
-rw-r--r--packages/frontend/src/components/MkNotification.vue36
-rw-r--r--packages/frontend/src/components/MkPasswordDialog.vue26
-rw-r--r--packages/frontend/src/components/MkPostForm.vue45
-rw-r--r--packages/frontend/src/components/MkPostFormDialog.vue6
-rw-r--r--packages/frontend/src/components/MkReactionsViewer.vue3
-rw-r--r--packages/frontend/src/components/MkSignin.vue11
-rw-r--r--packages/frontend/src/components/MkSignupDialog.rules.stories.impl.ts6
-rw-r--r--packages/frontend/src/components/MkSwitch.button.vue13
-rw-r--r--packages/frontend/src/components/MkTutorialDialog.Note.vue1
-rw-r--r--packages/frontend/src/components/MkTutorialDialog.PostNote.vue1
-rw-r--r--packages/frontend/src/components/MkTutorialDialog.Sensitive.vue1
-rw-r--r--packages/frontend/src/components/MkUrlPreview.vue11
-rw-r--r--packages/frontend/src/components/MkUrlPreviewPopup.vue4
-rw-r--r--packages/frontend/src/components/MkUserPopup.vue4
-rw-r--r--packages/frontend/src/components/MkVisitorDashboard.vue50
-rw-r--r--packages/frontend/src/components/global/I18n.vue5
-rw-r--r--packages/frontend/src/components/global/MkA.vue24
-rw-r--r--packages/frontend/src/components/global/MkAd.stories.impl.ts28
-rw-r--r--packages/frontend/src/components/global/MkAd.vue21
-rw-r--r--packages/frontend/src/components/global/MkAvatar.stories.impl.ts3
-rw-r--r--packages/frontend/src/components/global/MkCondensedLine.stories.impl.ts2
-rw-r--r--packages/frontend/src/components/global/MkError.stories.meta.ts7
-rw-r--r--packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts7
-rw-r--r--packages/frontend/src/components/global/MkPageHeader.stories.impl.ts5
-rw-r--r--packages/frontend/src/components/global/MkPageHeader.tabs.vue1
-rw-r--r--packages/frontend/src/components/global/MkTime.stories.impl.ts14
-rw-r--r--packages/frontend/src/components/global/MkTime.vue4
-rw-r--r--packages/frontend/src/components/global/MkUrl.vue8
-rw-r--r--packages/frontend/src/components/global/MkUserName.stories.impl.ts2
-rw-r--r--packages/frontend/src/components/page/page.block.vue15
-rw-r--r--packages/frontend/src/components/page/page.dynamic.vue43
-rw-r--r--packages/frontend/src/components/page/page.image.vue24
-rw-r--r--packages/frontend/src/components/page/page.note.vue13
-rw-r--r--packages/frontend/src/components/page/page.text.vue13
-rw-r--r--packages/frontend/src/components/page/page.vue2
-rw-r--r--packages/frontend/src/filters/kmg.ts5
-rw-r--r--packages/frontend/src/index.html2
-rw-r--r--packages/frontend/src/instance.ts12
-rw-r--r--packages/frontend/src/nirax.ts20
-rw-r--r--packages/frontend/src/os.ts2
-rw-r--r--packages/frontend/src/pages/about-misskey.vue20
-rw-r--r--packages/frontend/src/pages/admin-user.vue2
-rw-r--r--packages/frontend/src/pages/admin/RolesEditorFormula.vue5
-rw-r--r--packages/frontend/src/pages/admin/federation.vue14
-rw-r--r--packages/frontend/src/pages/admin/files.vue27
-rw-r--r--packages/frontend/src/pages/admin/index.vue37
-rw-r--r--packages/frontend/src/pages/admin/moderation.vue9
-rw-r--r--packages/frontend/src/pages/admin/roles.role.vue2
-rw-r--r--packages/frontend/src/pages/admin/security.vue16
-rw-r--r--packages/frontend/src/pages/admin/settings.vue74
-rw-r--r--packages/frontend/src/pages/admin/users.vue2
-rw-r--r--packages/frontend/src/pages/announcement.vue142
-rw-r--r--packages/frontend/src/pages/announcements.vue25
-rw-r--r--packages/frontend/src/pages/channel.vue3
-rw-r--r--packages/frontend/src/pages/clip.vue13
-rw-r--r--packages/frontend/src/pages/contact.vue40
-rw-r--r--packages/frontend/src/pages/explore.featured.vue3
-rw-r--r--packages/frontend/src/pages/flash/flash-edit.vue62
-rw-r--r--packages/frontend/src/pages/flash/flash.vue82
-rw-r--r--packages/frontend/src/pages/instance-info.vue29
-rw-r--r--packages/frontend/src/pages/my-antennas/create.vue1
-rw-r--r--packages/frontend/src/pages/my-antennas/editor.vue6
-rw-r--r--packages/frontend/src/pages/my-clips/index.vue10
-rw-r--r--packages/frontend/src/pages/note.vue7
-rw-r--r--packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue2
-rw-r--r--packages/frontend/src/pages/page.vue390
-rw-r--r--packages/frontend/src/pages/reversi/game.board.vue3
-rw-r--r--packages/frontend/src/pages/settings/2fa.qrdialog.vue21
-rw-r--r--packages/frontend/src/pages/settings/2fa.vue12
-rw-r--r--packages/frontend/src/pages/settings/drive.vue5
-rw-r--r--packages/frontend/src/pages/settings/general.vue12
-rw-r--r--packages/frontend/src/pages/settings/plugin.vue20
-rw-r--r--packages/frontend/src/pages/settings/preferences-backups.vue1
-rw-r--r--packages/frontend/src/pages/share.vue29
-rw-r--r--packages/frontend/src/pages/timeline.vue6
-rw-r--r--packages/frontend/src/pages/welcome.entrance.a.vue35
-rw-r--r--packages/frontend/src/pages/welcome.vue12
-rw-r--r--packages/frontend/src/plugin.ts25
-rw-r--r--packages/frontend/src/router/definition.ts8
-rw-r--r--packages/frontend/src/scripts/admin-lookup.ts (renamed from packages/frontend/src/scripts/lookup-user.ts)23
-rw-r--r--packages/frontend/src/scripts/aiscript/ui.ts62
-rw-r--r--packages/frontend/src/scripts/check-reaction-permissions.ts5
-rw-r--r--packages/frontend/src/scripts/clear-cache.ts5
-rw-r--r--packages/frontend/src/scripts/code-highlighter.ts23
-rw-r--r--packages/frontend/src/scripts/collapsed.ts19
-rw-r--r--packages/frontend/src/scripts/form.ts30
-rw-r--r--packages/frontend/src/scripts/get-note-menu.ts79
-rw-r--r--packages/frontend/src/scripts/get-user-menu.ts2
-rw-r--r--packages/frontend/src/scripts/idb-proxy.ts10
-rw-r--r--packages/frontend/src/scripts/keycode.ts1
-rw-r--r--packages/frontend/src/scripts/media-has-audio.ts5
-rw-r--r--packages/frontend/src/scripts/popup-position.ts38
-rw-r--r--packages/frontend/src/scripts/snowfall-effect.ts4
-rw-r--r--packages/frontend/src/scripts/theme.ts4
-rw-r--r--packages/frontend/src/scripts/upload.ts10
-rw-r--r--packages/frontend/src/scripts/use-chart-tooltip.ts6
-rw-r--r--packages/frontend/src/scripts/use-note-capture.ts2
-rw-r--r--packages/frontend/src/store.ts25
-rw-r--r--packages/frontend/src/stream.ts22
-rw-r--r--packages/frontend/src/style.scss18
-rw-r--r--packages/frontend/src/type.ts5
-rw-r--r--packages/frontend/src/types/menu.ts10
-rw-r--r--packages/frontend/src/ui/_common_/announcements.vue2
-rw-r--r--packages/frontend/src/ui/_common_/common.ts11
-rw-r--r--packages/frontend/src/ui/_common_/statusbar-rss.vue5
-rw-r--r--packages/frontend/src/ui/deck/antenna-column.vue24
-rw-r--r--packages/frontend/src/ui/deck/channel-column.vue36
-rw-r--r--packages/frontend/src/ui/deck/deck-store.ts2
-rw-r--r--packages/frontend/src/ui/deck/list-column.vue22
-rw-r--r--packages/frontend/src/ui/deck/role-timeline-column.vue23
-rw-r--r--packages/frontend/src/ui/deck/tl-column.vue20
-rw-r--r--packages/frontend/src/ui/deck/tl-note-notification.ts107
-rw-r--r--packages/frontend/src/ui/visitor.vue7
-rw-r--r--packages/frontend/src/widgets/WidgetBirthdayFollowings.vue35
-rw-r--r--packages/frontend/src/widgets/WidgetRss.vue7
-rw-r--r--packages/frontend/src/widgets/WidgetRssTicker.vue7
-rw-r--r--packages/frontend/src/widgets/WidgetUnixClock.vue6
146 files changed, 2578 insertions, 835 deletions
diff --git a/packages/frontend/src/_dev_boot_.ts b/packages/frontend/src/_dev_boot_.ts
index eceec76c51..7c6e537fbc 100644
--- a/packages/frontend/src/_dev_boot_.ts
+++ b/packages/frontend/src/_dev_boot_.ts
@@ -6,7 +6,7 @@
// devモードで起動される際(index.htmlを使うとき)はrouterが暴発してしまってうまく読み込めない。
// よって、devモードとして起動されるときはビルド時に組み込む形としておく。
// (pnpm start時はpugファイルの中で静的リソースとして読み込むようになっており、この問題は起こっていない)
-import '@tabler/icons-webfont/tabler-icons.scss';
+import '@tabler/icons-webfont/dist/tabler-icons.scss';
await main();
diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts
index 681beaf00f..d86ae18ffe 100644
--- a/packages/frontend/src/boot/common.ts
+++ b/packages/frontend/src/boot/common.ts
@@ -145,8 +145,11 @@ export async function common(createVue: () => App<Element>) {
// NOTE: この処理は必ずクライアント更新チェック処理より後に来ること(テーマ再構築のため)
watch(defaultStore.reactiveState.darkMode, (darkMode) => {
applyTheme(darkMode ? ColdDeviceStorage.get('darkTheme') : ColdDeviceStorage.get('lightTheme'));
+ document.documentElement.dataset.colorMode = darkMode ? 'dark' : 'light';
}, { immediate: miLocalStorage.getItem('theme') == null });
+ document.documentElement.dataset.colorMode = defaultStore.state.darkMode ? 'dark' : 'light';
+
const darkTheme = computed(ColdDeviceStorage.makeGetterSetter('darkTheme'));
const lightTheme = computed(ColdDeviceStorage.makeGetterSetter('lightTheme'));
diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts
index 61f04678bf..5cb19f388a 100644
--- a/packages/frontend/src/boot/main-boot.ts
+++ b/packages/frontend/src/boot/main-boot.ts
@@ -75,27 +75,31 @@ export async function mainBoot() {
mainRouter.push('/search');
},
};
-
- if (defaultStore.state.enableSeasonalScreenEffect) {
- const month = new Date().getMonth() + 1;
- if (defaultStore.state.hemisphere === 'S') {
- // ▼南半球
- if (month === 7 || month === 8) {
- const SnowfallEffect = (await import('@/scripts/snowfall-effect.js')).SnowfallEffect;
- new SnowfallEffect({}).render();
- }
- } else {
- // ▼北半球
- if (month === 12 || month === 1) {
- const SnowfallEffect = (await import('@/scripts/snowfall-effect.js')).SnowfallEffect;
- new SnowfallEffect({}).render();
- } else if (month === 3 || month === 4) {
- const SakuraEffect = (await import('@/scripts/snowfall-effect.js')).SnowfallEffect;
- new SakuraEffect({
- sakura: true,
- }).render();
+ try {
+ if (defaultStore.state.enableSeasonalScreenEffect) {
+ const month = new Date().getMonth() + 1;
+ if (defaultStore.state.hemisphere === 'S') {
+ // ▼南半球
+ if (month === 7 || month === 8) {
+ const SnowfallEffect = (await import('@/scripts/snowfall-effect.js')).SnowfallEffect;
+ new SnowfallEffect({}).render();
+ }
+ } else {
+ // ▼北半球
+ if (month === 12 || month === 1) {
+ const SnowfallEffect = (await import('@/scripts/snowfall-effect.js')).SnowfallEffect;
+ new SnowfallEffect({}).render();
+ } else if (month === 3 || month === 4) {
+ const SakuraEffect = (await import('@/scripts/snowfall-effect.js')).SnowfallEffect;
+ new SakuraEffect({
+ sakura: true,
+ }).render();
+ }
}
- }
+ }
+ } catch (error) {
+ // console.error(error);
+ console.error('Failed to initialise the seasonal screen effect canvas context:', error);
}
if ($i) {
@@ -187,14 +191,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,7 +245,7 @@ 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');
}
diff --git a/packages/frontend/src/cache.ts b/packages/frontend/src/cache.ts
index b286528de6..bfe8fbe0e4 100644
--- a/packages/frontend/src/cache.ts
+++ b/packages/frontend/src/cache.ts
@@ -11,3 +11,4 @@ export const clipsCache = new Cache<Misskey.entities.Clip[]>(1000 * 60 * 30, ()
export const rolesCache = new Cache(1000 * 60 * 30, () => misskeyApi('admin/roles/list'));
export const userListsCache = new Cache<Misskey.entities.UserList[]>(1000 * 60 * 30, () => misskeyApi('users/lists/list'));
export const antennasCache = new Cache<Misskey.entities.Antenna[]>(1000 * 60 * 30, () => misskeyApi('antennas/list'));
+export const favoritedChannelsCache = new Cache<Misskey.entities.Channel[]>(1000 * 60 * 30, () => misskeyApi('channels/my-favorites', { limit: 100 }));
diff --git a/packages/frontend/src/components/MkAbuseReport.vue b/packages/frontend/src/components/MkAbuseReport.vue
index 271b94feaa..a28e7c2559 100644
--- a/packages/frontend/src/components/MkAbuseReport.vue
+++ b/packages/frontend/src/components/MkAbuseReport.vue
@@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div class="detail">
<div>
- <Mfm :text="report.comment"/>
+ <Mfm :text="report.comment" :linkNavigationBehavior="'window'"/>
</div>
<hr/>
<div>{{ i18n.ts.reporter }}: <MkA :to="`/admin/user/${report.reporter.id}`" class="_link" :behavior="'window'">@{{ report.reporter.username }}</MkA></div>
diff --git a/packages/frontend/src/components/MkAccountMoved.stories.impl.ts b/packages/frontend/src/components/MkAccountMoved.stories.impl.ts
index f1cfdc157a..cad26de6e2 100644
--- a/packages/frontend/src/components/MkAccountMoved.stories.impl.ts
+++ b/packages/frontend/src/components/MkAccountMoved.stories.impl.ts
@@ -4,7 +4,10 @@
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import { action } from '@storybook/addon-actions';
import { StoryObj } from '@storybook/vue3';
+import { HttpResponse, http } from 'msw';
+import { commonHandlers } from '../../.storybook/mocks.js';
import { userDetailed } from '../../.storybook/fakes.js';
import MkAccountMoved from './MkAccountMoved.vue';
export const Default = {
@@ -29,10 +32,18 @@ export const Default = {
};
},
args: {
- username: userDetailed().username,
- host: userDetailed().host,
+ movedTo: userDetailed().id,
},
parameters: {
layout: 'centered',
+ msw: {
+ handlers: [
+ ...commonHandlers,
+ http.post('/api/users/show', async ({ request }) => {
+ action('POST /api/users/show')(await request.json());
+ return HttpResponse.json(userDetailed());
+ }),
+ ],
+ },
},
} satisfies StoryObj<typeof MkAccountMoved>;
diff --git a/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts b/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts
index ffa4e56f5f..bf3ddb935b 100644
--- a/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts
+++ b/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts
@@ -4,7 +4,10 @@
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import { action } from '@storybook/addon-actions';
import { StoryObj } from '@storybook/vue3';
+import { HttpResponse, http } from 'msw';
+import { commonHandlers } from '../../.storybook/mocks.js';
import MkAnnouncementDialog from './MkAnnouncementDialog.vue';
export const Default = {
render(args) {
@@ -23,8 +26,13 @@ export const Default = {
...this.args,
};
},
+ events() {
+ return {
+ closed: action('closed'),
+ };
+ },
},
- template: '<MkAnnouncementDialog v-bind="props" />',
+ template: '<MkAnnouncementDialog v-bind="props" v-on="events" />',
};
},
args: {
@@ -38,10 +46,20 @@ export const Default = {
imageUrl: null,
display: 'dialog',
needConfirmationToRead: false,
+ silence: false,
forYou: true,
},
},
parameters: {
layout: 'centered',
+ msw: {
+ handlers: [
+ ...commonHandlers,
+ http.post('/api/i/read-announcement', async ({ request }) => {
+ action('POST /api/i/read-announcement')(await request.json());
+ return HttpResponse.json();
+ }),
+ ],
+ },
},
} satisfies StoryObj<typeof MkAnnouncementDialog>;
diff --git a/packages/frontend/src/components/MkAsUi.vue b/packages/frontend/src/components/MkAsUi.vue
index 5eb77740be..18e8e7542e 100644
--- a/packages/frontend/src/components/MkAsUi.vue
+++ b/packages/frontend/src/components/MkAsUi.vue
@@ -44,6 +44,8 @@ SPDX-License-Identifier: AGPL-3.0-only
:instant="true"
:initialText="c.form?.text"
:initialCw="c.form?.cw"
+ :initialVisibility="c.form?.visibility"
+ :initialLocalOnly="c.form?.localOnly"
/>
</div>
<MkFolder v-else-if="c.type === 'folder'" :defaultOpen="c.opened">
@@ -111,6 +113,8 @@ function openPostForm() {
os.post({
initialText: form.text,
initialCw: form.cw,
+ initialVisibility: form.visibility,
+ initialLocalOnly: form.localOnly,
instant: true,
});
}
diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue
index 817f1aadf3..3489255b91 100644
--- a/packages/frontend/src/components/MkButton.vue
+++ b/packages/frontend/src/components/MkButton.vue
@@ -23,6 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
v-else class="_button"
:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike }]"
:to="to ?? '#'"
+ :behavior="linkBehavior"
@mousedown="onMousedown"
>
<div ref="ripples" :class="$style.ripples" :data-children-class="$style.ripple"></div>
@@ -43,6 +44,7 @@ const props = defineProps<{
inline?: boolean;
link?: boolean;
to?: string;
+ linkBehavior?: null | 'window' | 'browser';
autofocus?: boolean;
wait?: boolean;
danger?: boolean;
diff --git a/packages/frontend/src/components/MkClipPreview.vue b/packages/frontend/src/components/MkClipPreview.vue
index c51ad4356d..6299a28e9f 100644
--- a/packages/frontend/src/components/MkClipPreview.vue
+++ b/packages/frontend/src/components/MkClipPreview.vue
@@ -4,37 +4,59 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
-<div :class="$style.root" class="_panel">
- <b>{{ clip.name }}</b>
- <div v-if="clip.description" :class="$style.description">{{ clip.description }}</div>
- <div v-if="clip.lastClippedAt">{{ i18n.ts.updatedAt }}: <MkTime :time="clip.lastClippedAt" mode="detail"/></div>
- <div :class="$style.user">
- <MkAvatar :user="clip.user" :class="$style.userAvatar" indicator link preview/> <MkUserName :user="clip.user" :nowrap="false"/>
+<MkA :to="`/clips/${clip.id}`" :class="$style.link">
+ <div :class="$style.root" class="_panel _gaps_s">
+ <b>{{ clip.name }}</b>
+ <div :class="$style.description">
+ <div v-if="clip.description"><Mfm :text="clip.description" :plain="true" :nowrap="true"/></div>
+ <div v-if="clip.lastClippedAt">{{ i18n.ts.updatedAt }}: <MkTime :time="clip.lastClippedAt" mode="detail"/></div>
+ <div v-if="clip.notesCount != null">{{ i18n.ts.notesCount }}: {{ number(clip.notesCount) }} / {{ $i?.policies.noteEachClipsLimit }} ({{ i18n.tsx.remainingN({ n: remaining }) }})</div>
+ </div>
+ <div :class="$style.divider"></div>
+ <div>
+ <MkAvatar :user="clip.user" :class="$style.userAvatar" indicator link preview/> <MkUserName :user="clip.user" :nowrap="false"/>
+ </div>
</div>
-</div>
+</MkA>
</template>
<script lang="ts" setup>
+import * as Misskey from 'misskey-js';
+import { computed } from 'vue';
import { i18n } from '@/i18n.js';
+import { $i } from '@/account.js';
+import number from '@/filters/number.js';
-defineProps<{
- clip: any;
+const props = defineProps<{
+ clip: Misskey.entities.Clip;
}>();
+
+const remaining = computed(() => {
+ return ($i?.policies && props.clip.notesCount != null) ? ($i.policies.noteEachClipsLimit - props.clip.notesCount) : i18n.ts.unknown;
+});
</script>
<style lang="scss" module>
-.root {
+.link {
display: block;
+
+ &:hover {
+ text-decoration: none;
+ color: var(--accent);
+ }
+}
+
+.root {
padding: 16px;
}
-.description {
- padding: 8px 0;
+.divider {
+ height: 1px;
+ background: var(--divider);
}
-.user {
- padding-top: 16px;
- border-top: solid 0.5px var(--divider);
+.description {
+ font-size: 90%;
}
.userAvatar {
diff --git a/packages/frontend/src/components/MkCode.core.vue b/packages/frontend/src/components/MkCode.core.vue
index 872517b6aa..c0e7df5dac 100644
--- a/packages/frontend/src/components/MkCode.core.vue
+++ b/packages/frontend/src/components/MkCode.core.vue
@@ -9,9 +9,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
-import { ref, computed, watch } from 'vue';
-import { bundledLanguagesInfo } from 'shiki';
-import type { BuiltinLanguage } from 'shiki';
+import { computed, ref, watch } from 'vue';
+import { bundledLanguagesInfo } from 'shiki/langs';
+import type { BundledLanguage } from 'shiki/langs';
import { getHighlighter, getTheme } from '@/scripts/code-highlighter.js';
import { defaultStore } from '@/store.js';
@@ -23,7 +23,7 @@ const props = defineProps<{
const highlighter = await getHighlighter();
const darkMode = defaultStore.reactiveState.darkMode;
-const codeLang = ref<BuiltinLanguage | 'aiscript'>('js');
+const codeLang = ref<BundledLanguage | 'aiscript'>('js');
const [lightThemeName, darkThemeName] = await Promise.all([
getTheme('light', true),
@@ -42,7 +42,7 @@ const html = computed(() => highlighter.codeToHtml(props.code, {
}));
async function fetchLanguage(to: string): Promise<void> {
- const language = to as BuiltinLanguage;
+ const language = to as BundledLanguage;
// Check for the loaded languages, and load the language if it's not loaded yet.
if (!highlighter.getLoadedLanguages().includes(language)) {
diff --git a/packages/frontend/src/components/MkCode.vue b/packages/frontend/src/components/MkCode.vue
index ede068b20d..a3c80e743b 100644
--- a/packages/frontend/src/components/MkCode.vue
+++ b/packages/frontend/src/components/MkCode.vue
@@ -80,11 +80,9 @@ function copy() {
.codePlaceholderRoot {
display: block;
width: 100%;
- background: none;
border: none;
outline: none;
font: inherit;
- color: inherit;
cursor: pointer;
box-sizing: border-box;
diff --git a/packages/frontend/src/components/MkContextMenu.vue b/packages/frontend/src/components/MkContextMenu.vue
index 5ca3c77fb2..a807742bb9 100644
--- a/packages/frontend/src/components/MkContextMenu.vue
+++ b/packages/frontend/src/components/MkContextMenu.vue
@@ -47,12 +47,12 @@ onMounted(() => {
const width = rootEl.value!.offsetWidth;
const height = rootEl.value!.offsetHeight;
- if (left + width - window.pageXOffset >= (window.innerWidth - SCROLLBAR_THICKNESS)) {
- left = (window.innerWidth - SCROLLBAR_THICKNESS) - width + window.pageXOffset;
+ if (left + width - window.scrollX >= (window.innerWidth - SCROLLBAR_THICKNESS)) {
+ left = (window.innerWidth - SCROLLBAR_THICKNESS) - width + window.scrollX;
}
- if (top + height - window.pageYOffset >= (window.innerHeight - SCROLLBAR_THICKNESS)) {
- top = (window.innerHeight - SCROLLBAR_THICKNESS) - height + window.pageYOffset;
+ if (top + height - window.scrollY >= (window.innerHeight - SCROLLBAR_THICKNESS)) {
+ top = (window.innerHeight - SCROLLBAR_THICKNESS) - height + window.scrollY;
}
if (top < 0) {
diff --git a/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue b/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue
index 84b5375a41..c7f1288729 100644
--- a/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue
+++ b/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue
@@ -4,77 +4,81 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
- <MkModalWindow ref="dialogEl" @close="cancel()" @closed="$emit('closed')">
- <template #header>:{{ emoji.name }}:</template>
- <template #default>
- <MkSpacer>
- <div style="display: flex; flex-direction: column; gap: 1em;">
- <div :class="$style.emojiImgWrapper">
- <MkCustomEmoji :name="emoji.name" :normal="true" :useOriginalSize="true" style="height: 100%;"></MkCustomEmoji>
- </div>
- <MkKeyValue :copy="`:${emoji.name}:`">
- <template #key>{{ i18n.ts.name }}</template>
- <template #value>{{ emoji.name }}</template>
- </MkKeyValue>
- <MkKeyValue>
- <template #key>{{ i18n.ts.tags }}</template>
- <template #value>
- <div v-if="emoji.aliases.length === 0">{{ i18n.ts.none }}</div>
- <div v-else :class="$style.aliases">
- <span v-for="alias in emoji.aliases" :key="alias" :class="$style.alias">
- {{ alias }}
- </span>
- </div>
- </template>
- </MkKeyValue>
- <MkKeyValue>
- <template #key>{{ i18n.ts.category }}</template>
- <template #value>{{ emoji.category ?? i18n.ts.none }}</template>
- </MkKeyValue>
- <MkKeyValue>
- <template #key>{{ i18n.ts.sensitive }}</template>
- <template #value>{{ emoji.isSensitive ? i18n.ts.yes : i18n.ts.no }}</template>
- </MkKeyValue>
- <MkKeyValue>
- <template #key>{{ i18n.ts.localOnly }}</template>
- <template #value>{{ emoji.localOnly ? i18n.ts.yes : i18n.ts.no }}</template>
- </MkKeyValue>
- <MkKeyValue>
- <template #key>{{ i18n.ts.license }}</template>
- <template #value><Mfm :text="emoji.license ?? i18n.ts.none" /></template>
- </MkKeyValue>
- <MkKeyValue :copy="emoji.url">
- <template #key>{{ i18n.ts.emojiUrl }}</template>
- <template #value>
- <MkLink :url="emoji.url" target="_blank">{{ emoji.url }}</MkLink>
- </template>
- </MkKeyValue>
- </div>
- </MkSpacer>
- </template>
- </MkModalWindow>
+<MkModalWindow ref="dialogEl" @close="cancel()" @closed="$emit('closed')">
+ <template #header>:{{ emoji.name }}:</template>
+ <template #default>
+ <MkSpacer>
+ <div style="display: flex; flex-direction: column; gap: 1em;">
+ <div :class="$style.emojiImgWrapper">
+ <MkCustomEmoji :name="emoji.name" :normal="true" :useOriginalSize="true" style="height: 100%;"></MkCustomEmoji>
+ </div>
+ <MkKeyValue :copy="`:${emoji.name}:`">
+ <template #key>{{ i18n.ts.name }}</template>
+ <template #value>{{ emoji.name }}</template>
+ </MkKeyValue>
+ <MkKeyValue>
+ <template #key>{{ i18n.ts.tags }}</template>
+ <template #value>
+ <div v-if="emoji.aliases.length === 0">{{ i18n.ts.none }}</div>
+ <div v-else :class="$style.aliases">
+ <span v-for="alias in emoji.aliases" :key="alias" :class="$style.alias">
+ {{ alias }}
+ </span>
+ </div>
+ </template>
+ </MkKeyValue>
+ <MkKeyValue>
+ <template #key>{{ i18n.ts.category }}</template>
+ <template #value>{{ emoji.category ?? i18n.ts.none }}</template>
+ </MkKeyValue>
+ <MkKeyValue>
+ <template #key>{{ i18n.ts.sensitive }}</template>
+ <template #value>{{ emoji.isSensitive ? i18n.ts.yes : i18n.ts.no }}</template>
+ </MkKeyValue>
+ <MkKeyValue>
+ <template #key>{{ i18n.ts.localOnly }}</template>
+ <template #value>{{ emoji.localOnly ? i18n.ts.yes : i18n.ts.no }}</template>
+ </MkKeyValue>
+ <MkKeyValue>
+ <template #key>{{ i18n.ts.license }}</template>
+ <template #value><Mfm :text="emoji.license ?? i18n.ts.none"/></template>
+ </MkKeyValue>
+ <MkKeyValue :copy="emoji.url">
+ <template #key>{{ i18n.ts.emojiUrl }}</template>
+ <template #value>
+ <MkLink :url="emoji.url" target="_blank">{{ emoji.url }}</MkLink>
+ </template>
+ </MkKeyValue>
+ </div>
+ </MkSpacer>
+ </template>
+</MkModalWindow>
</template>
<script lang="ts" setup>
import * as Misskey from 'misskey-js';
import { defineProps, shallowRef } from 'vue';
+import MkLink from '@/components/MkLink.vue';
import { i18n } from '@/i18n.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkKeyValue from '@/components/MkKeyValue.vue';
-import MkLink from './MkLink.vue';
+
const props = defineProps<{
emoji: Misskey.entities.EmojiDetailed,
}>();
+
const emit = defineEmits<{
(ev: 'ok', cropped: Misskey.entities.DriveFile): void;
(ev: 'cancel'): void;
(ev: 'closed'): void;
}>();
+
const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>();
-const cancel = () => {
+
+function cancel() {
emit('cancel');
dialogEl.value!.close();
-};
+}
</script>
<style lang="scss" module>
diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue
index 4577d37c08..c52404a319 100644
--- a/packages/frontend/src/components/MkDialog.vue
+++ b/packages/frontend/src/components/MkDialog.vue
@@ -161,7 +161,7 @@ function onKeydown(evt: KeyboardEvent) {
}
function onInputKeydown(evt: KeyboardEvent) {
- if (evt.key === 'Enter') {
+ if (evt.key === 'Enter' && okButtonDisabledReason.value === null) {
evt.preventDefault();
evt.stopPropagation();
ok();
diff --git a/packages/frontend/src/components/MkFeaturedPhotos.vue b/packages/frontend/src/components/MkFeaturedPhotos.vue
index 8d875790bc..c42c692db0 100644
--- a/packages/frontend/src/components/MkFeaturedPhotos.vue
+++ b/packages/frontend/src/components/MkFeaturedPhotos.vue
@@ -4,19 +4,11 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
-<div v-if="meta" :class="$style.root" :style="{ backgroundImage: `url(${ meta.backgroundImageUrl })` }"></div>
+<div v-if="instance" :class="$style.root" :style="{ backgroundImage: `url(${ instance.backgroundImageUrl })` }"></div>
</template>
<script lang="ts" setup>
-import { ref } from 'vue';
-import * as Misskey from 'misskey-js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-
-const meta = ref<Misskey.entities.MetaResponse>();
-
-misskeyApi('meta', { detail: true }).then(gotMeta => {
- meta.value = gotMeta;
-});
+import { instance } from '@/instance.js';
</script>
<style lang="scss" module>
diff --git a/packages/frontend/src/components/MkFollowButton.vue b/packages/frontend/src/components/MkFollowButton.vue
index 28450e11fc..636e61db8f 100644
--- a/packages/frontend/src/components/MkFollowButton.vue
+++ b/packages/frontend/src/components/MkFollowButton.vue
@@ -93,6 +93,18 @@ async function onClick() {
userId: props.user.id,
});
} else {
+ if (defaultStore.state.alwaysConfirmFollow) {
+ const { canceled } = await os.confirm({
+ type: 'question',
+ text: i18n.tsx.followConfirm({ name: props.user.name || props.user.username }),
+ });
+
+ if (canceled) {
+ wait.value = false;
+ return;
+ }
+ }
+
if (hasPendingFollowRequestFromYou.value) {
await misskeyApi('following/requests/cancel', {
userId: props.user.id,
diff --git a/packages/frontend/src/components/MkFormDialog.file.vue b/packages/frontend/src/components/MkFormDialog.file.vue
new file mode 100644
index 0000000000..9360594236
--- /dev/null
+++ b/packages/frontend/src/components/MkFormDialog.file.vue
@@ -0,0 +1,71 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div>
+ <MkButton inline rounded primary @click="selectButton($event)">{{ i18n.ts.selectFile }}</MkButton>
+ <div :class="['_nowrap', !fileName && $style.fileNotSelected]">{{ friendlyFileName }}</div>
+</div>
+</template>
+
+<script setup lang="ts">
+import * as Misskey from 'misskey-js';
+import { computed, ref } from 'vue';
+import { i18n } from '@/i18n.js';
+import MkButton from '@/components/MkButton.vue';
+import { selectFile } from '@/scripts/select-file.js';
+import { misskeyApi } from '@/scripts/misskey-api.js';
+
+const props = defineProps<{
+ fileId?: string | null;
+ validate?: (file: Misskey.entities.DriveFile) => Promise<boolean>;
+}>();
+
+const emit = defineEmits<{
+ (ev: 'update', result: Misskey.entities.DriveFile): void;
+}>();
+
+const fileUrl = ref('');
+const fileName = ref<string>('');
+
+const friendlyFileName = computed<string>(() => {
+ if (fileName.value) {
+ return fileName.value;
+ }
+ if (fileUrl.value) {
+ return fileUrl.value;
+ }
+
+ return i18n.ts.fileNotSelected;
+});
+
+if (props.fileId) {
+ misskeyApi('drive/files/show', {
+ fileId: props.fileId,
+ }).then((apiRes) => {
+ fileName.value = apiRes.name;
+ fileUrl.value = apiRes.url;
+ });
+}
+
+function selectButton(ev: MouseEvent) {
+ selectFile(ev.currentTarget ?? ev.target).then(async (file) => {
+ if (!file) return;
+ if (props.validate && !await props.validate(file)) return;
+
+ emit('update', file);
+ fileName.value = file.name;
+ fileUrl.value = file.url;
+ });
+}
+
+</script>
+
+<style module>
+.fileNotSelected {
+ font-weight: 700;
+ color: var(--infoWarnFg);
+}
+</style>
diff --git a/packages/frontend/src/components/MkFormDialog.vue b/packages/frontend/src/components/MkFormDialog.vue
index deedc5badb..124f114111 100644
--- a/packages/frontend/src/components/MkFormDialog.vue
+++ b/packages/frontend/src/components/MkFormDialog.vue
@@ -21,8 +21,9 @@ 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="(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 v-for="(v, k) in Object.fromEntries(Object.entries(form))">
+ <template v-if="typeof v.hidden == 'function' ? v.hidden(values) : v.hidden"></template>
+ <MkInput v-else-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>
@@ -53,6 +54,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton v-else-if="v.type === 'button'" @click="v.action($event, values)">
<span v-text="v.content || k"></span>
</MkButton>
+ <XFile
+ v-else-if="v.type === 'drive-file'"
+ :fileId="v.defaultFileId"
+ :validate="async f => !v.validate || await v.validate(f)"
+ @update="f => values[k] = f"
+ />
</template>
</div>
<div v-else class="_fullinfo">
@@ -72,6 +79,7 @@ import MkSelect from './MkSelect.vue';
import MkRange from './MkRange.vue';
import MkButton from './MkButton.vue';
import MkRadios from './MkRadios.vue';
+import XFile from './MkFormDialog.file.vue';
import type { Form } from '@/scripts/form.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import { i18n } from '@/i18n.js';
diff --git a/packages/frontend/src/components/MkInput.vue b/packages/frontend/src/components/MkInput.vue
index d3cddad15b..88ef4635e6 100644
--- a/packages/frontend/src/components/MkInput.vue
+++ b/packages/frontend/src/components/MkInput.vue
@@ -22,6 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:autocomplete="autocomplete"
:autocapitalize="autocapitalize"
:spellcheck="spellcheck"
+ :inputmode="inputmode"
:step="step"
:list="id"
:min="min"
@@ -63,6 +64,7 @@ const props = defineProps<{
mfmAutocomplete?: boolean | SuggestionType[],
autocapitalize?: string;
spellcheck?: boolean;
+ inputmode?: 'none' | 'text' | 'search' | 'email' | 'url' | 'numeric' | 'tel' | 'decimal';
step?: any;
datalist?: string[];
min?: number;
diff --git a/packages/frontend/src/components/MkLink.vue b/packages/frontend/src/components/MkLink.vue
index a5abbeceac..5d54a58e97 100644
--- a/packages/frontend/src/components/MkLink.vue
+++ b/packages/frontend/src/components/MkLink.vue
@@ -6,6 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<component
:is="self ? 'MkA' : 'a'" ref="el" style="word-break: break-all;" class="_link" :[attr]="self ? url.substring(local.length) : url" :rel="rel ?? 'nofollow noopener'" :target="target"
+ :behavior="props.navigationBehavior"
:title="url"
>
<slot></slot>
@@ -18,10 +19,13 @@ import { defineAsyncComponent, ref } from 'vue';
import { url as local } from '@/config.js';
import { useTooltip } from '@/scripts/use-tooltip.js';
import * as os from '@/os.js';
+import { isEnabledUrlPreview } from '@/instance.js';
+import { MkABehavior } from '@/components/global/MkA.vue';
const props = withDefaults(defineProps<{
url: string;
rel?: null | string;
+ navigationBehavior?: MkABehavior;
}>(), {
});
@@ -29,15 +33,17 @@ const self = props.url.startsWith(local);
const attr = self ? 'to' : 'href';
const target = self ? null : '_blank';
-const el = ref<HTMLElement>();
+const el = ref<HTMLElement | { $el: HTMLElement }>();
-useTooltip(el, (showing) => {
- os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), {
- showing,
- url: props.url,
- source: el.value,
- }, {}, 'closed');
-});
+if (isEnabledUrlPreview.value) {
+ useTooltip(el, (showing) => {
+ os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), {
+ showing,
+ url: props.url,
+ source: el.value instanceof HTMLElement ? el.value : el.value?.$el,
+ }, {}, 'closed');
+ });
+}
</script>
<style lang="scss" module>
diff --git a/packages/frontend/src/components/MkMediaAudio.vue b/packages/frontend/src/components/MkMediaAudio.vue
index d42146f941..5d2edf467e 100644
--- a/packages/frontend/src/components/MkMediaAudio.vue
+++ b/packages/frontend/src/components/MkMediaAudio.vue
@@ -5,11 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div
+ ref="playerEl"
+ v-hotkey="keymap"
+ tabindex="0"
:class="[
$style.audioContainer,
(audio.isSensitive && defaultStore.state.highlightSensitiveMedia) && $style.sensitive,
]"
@contextmenu.stop
+ @keydown.stop
>
<button v-if="hide" :class="$style.hidden" @click="hide = false">
<div :class="$style.hiddenTextWrapper">
@@ -18,6 +22,19 @@ SPDX-License-Identifier: AGPL-3.0-only
<span style="display: block;">{{ i18n.ts.clickToShow }}</span>
</div>
</button>
+
+ <div v-else-if="defaultStore.reactiveState.useNativeUIForVideoAudioPlayer.value" :class="$style.nativeAudioContainer">
+ <audio
+ ref="audioEl"
+ preload="metadata"
+ controls
+ :class="$style.nativeAudio"
+ @keydown.prevent
+ >
+ <source :src="audio.url">
+ </audio>
+ </div>
+
<div v-else :class="$style.audioControls">
<audio
ref="audioEl"
@@ -66,12 +83,47 @@ 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;
}>();
+const keymap = {
+ 'up': () => {
+ if (hasFocus() && audioEl.value) {
+ volume.value = Math.min(volume.value + 0.1, 1);
+ }
+ },
+ 'down': () => {
+ if (hasFocus() && audioEl.value) {
+ volume.value = Math.max(volume.value - 0.1, 0);
+ }
+ },
+ 'left': () => {
+ if (hasFocus() && audioEl.value) {
+ audioEl.value.currentTime = Math.max(audioEl.value.currentTime - 5, 0);
+ }
+ },
+ 'right': () => {
+ if (hasFocus() && audioEl.value) {
+ audioEl.value.currentTime = Math.min(audioEl.value.currentTime + 5, audioEl.value.duration);
+ }
+ },
+ 'space': () => {
+ if (hasFocus()) {
+ togglePlayPause();
+ }
+ },
+};
+
+// PlayerElもしくはその子要素にフォーカスがあるかどうか
+function hasFocus() {
+ if (!playerEl.value) return false;
+ return playerEl.value === document.activeElement || playerEl.value.contains(document.activeElement);
+}
+
+const playerEl = shallowRef<HTMLDivElement>();
const audioEl = shallowRef<HTMLAudioElement>();
// eslint-disable-next-line vue/no-setup-props-destructure
@@ -86,6 +138,30 @@ function showMenu(ev: MouseEvent) {
menu = [
// TODO: 再生キューに追加
{
+ type: 'switch',
+ text: i18n.ts._mediaControls.loop,
+ icon: 'ti ti-repeat',
+ ref: loop,
+ },
+ {
+ type: 'radio',
+ text: i18n.ts._mediaControls.playbackRate,
+ icon: 'ti ti-clock-play',
+ ref: speed,
+ options: {
+ '0.25x': 0.25,
+ '0.5x': 0.5,
+ '0.75x': 0.75,
+ '1.0x': 1,
+ '1.25x': 1.25,
+ '1.5x': 1.5,
+ '2.0x': 2,
+ },
+ },
+ {
+ type: 'divider',
+ },
+ {
text: i18n.ts.hide,
icon: 'ti ti-eye-off',
action: () => {
@@ -96,8 +172,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 +179,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',
@@ -138,6 +223,8 @@ const rangePercent = computed({
},
});
const volume = ref(.25);
+const speed = ref(1);
+const loop = ref(false); // TODO: ドライブファイルのフラグに置き換える
const bufferedEnd = ref(0);
const bufferedDataRatio = computed(() => {
if (!audioEl.value) return 0;
@@ -167,6 +254,7 @@ function toggleMute() {
}
let onceInit = false;
+let mediaTickFrameId: number | null = null;
let stopAudioElWatch: () => void;
function init() {
@@ -186,8 +274,12 @@ function init() {
}
elapsedTimeMs.value = audioEl.value.currentTime * 1000;
+
+ if (audioEl.value.loop !== loop.value) {
+ loop.value = audioEl.value.loop;
+ }
}
- window.requestAnimationFrame(updateMediaTick);
+ mediaTickFrameId = window.requestAnimationFrame(updateMediaTick);
}
updateMediaTick();
@@ -225,6 +317,14 @@ watch(volume, (to) => {
if (audioEl.value) audioEl.value.volume = to;
});
+watch(speed, (to) => {
+ if (audioEl.value) audioEl.value.playbackRate = to;
+});
+
+watch(loop, (to) => {
+ if (audioEl.value) audioEl.value.loop = to;
+});
+
onMounted(() => {
init();
});
@@ -243,6 +343,10 @@ onDeactivated(() => {
hide.value = (defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.audio.isSensitive && defaultStore.state.nsfw !== 'ignore');
stopAudioElWatch();
onceInit = false;
+ if (mediaTickFrameId) {
+ window.cancelAnimationFrame(mediaTickFrameId);
+ mediaTickFrameId = null;
+ }
});
</script>
@@ -253,6 +357,10 @@ onDeactivated(() => {
border: .5px solid var(--divider);
border-radius: var(--radius);
overflow: clip;
+
+ &:focus {
+ outline: none;
+ }
}
.sensitive {
@@ -358,4 +466,15 @@ onDeactivated(() => {
}
}
}
+
+.nativeAudioContainer {
+ display: flex;
+ align-items: center;
+ padding: 6px;
+}
+
+.nativeAudio {
+ display: block;
+ width: 100%;
+}
</style>
diff --git a/packages/frontend/src/components/MkMediaImage.vue b/packages/frontend/src/components/MkMediaImage.vue
index 4ba2c76133..82f36fe5c4 100644
--- a/packages/frontend/src/components/MkMediaImage.vue
+++ b/packages/frontend/src/components/MkMediaImage.vue
@@ -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);
}
diff --git a/packages/frontend/src/components/MkMediaVideo.vue b/packages/frontend/src/components/MkMediaVideo.vue
index eab4fdfd6b..1e3868bc36 100644
--- a/packages/frontend/src/components/MkMediaVideo.vue
+++ b/packages/frontend/src/components/MkMediaVideo.vue
@@ -6,6 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div
ref="playerEl"
+ v-hotkey="keymap"
+ tabindex="0"
:class="[
$style.videoContainer,
controlsShowing && $style.active,
@@ -14,15 +16,37 @@ SPDX-License-Identifier: AGPL-3.0-only
@mouseover="onMouseOver"
@mouseleave="onMouseLeave"
@contextmenu.stop
+ @keydown.stop
>
<button v-if="hide" :class="$style.hidden" @click="hide = false">
<div :class="$style.hiddenTextWrapper">
<b v-if="video.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.dataSaver.media ? ` (${i18n.ts.video}${video.size ? ' ' + bytes(video.size) : ''})` : '' }}</b>
- <b v-else style="display: block;"><i class="ti ti-photo"></i> {{ defaultStore.state.dataSaver.media && video.size ? bytes(video.size) : i18n.ts.video }}</b>
+ <b v-else style="display: block;"><i class="ti ti-movie"></i> {{ defaultStore.state.dataSaver.media && video.size ? bytes(video.size) : i18n.ts.video }}</b>
<span style="display: block;">{{ i18n.ts.clickToShow }}</span>
</div>
</button>
- <div v-else :class="$style.videoRoot" @click.self="togglePlayPause">
+
+ <div v-else-if="defaultStore.reactiveState.useNativeUIForVideoAudioPlayer.value" :class="$style.videoRoot">
+ <video
+ ref="videoEl"
+ :class="$style.video"
+ :poster="video.thumbnailUrl ?? undefined"
+ :title="video.comment ?? undefined"
+ :alt="video.comment"
+ preload="metadata"
+ controls
+ @keydown.prevent
+ >
+ <source :src="video.url">
+ </video>
+ <i class="ti ti-eye-off" :class="$style.hide" @click="hide = true"></i>
+ <div :class="$style.indicators">
+ <div v-if="video.comment" :class="$style.indicator">ALT</div>
+ <div v-if="video.isSensitive" :class="$style.indicator" style="color: var(--warn);" :title="i18n.ts.sensitive"><i class="ti ti-eye-exclamation"></i></div>
+ </div>
+ </div>
+
+ <div v-else :class="$style.videoRoot">
<video
ref="videoEl"
:class="$style.video"
@@ -31,6 +55,8 @@ SPDX-License-Identifier: AGPL-3.0-only
:alt="video.comment"
preload="metadata"
playsinline
+ @keydown.prevent
+ @click.self="togglePlayPause"
>
<source :src="video.url">
</video>
@@ -94,12 +120,46 @@ 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;
}>();
+const keymap = {
+ 'up': () => {
+ if (hasFocus() && videoEl.value) {
+ volume.value = Math.min(volume.value + 0.1, 1);
+ }
+ },
+ 'down': () => {
+ if (hasFocus() && videoEl.value) {
+ volume.value = Math.max(volume.value - 0.1, 0);
+ }
+ },
+ 'left': () => {
+ if (hasFocus() && videoEl.value) {
+ videoEl.value.currentTime = Math.max(videoEl.value.currentTime - 5, 0);
+ }
+ },
+ 'right': () => {
+ if (hasFocus() && videoEl.value) {
+ videoEl.value.currentTime = Math.min(videoEl.value.currentTime + 5, videoEl.value.duration);
+ }
+ },
+ 'space': () => {
+ if (hasFocus()) {
+ togglePlayPause();
+ }
+ },
+};
+
+// PlayerElもしくはその子要素にフォーカスがあるかどうか
+function hasFocus() {
+ if (!playerEl.value) return false;
+ return playerEl.value === document.activeElement || playerEl.value.contains(document.activeElement);
+}
+
// eslint-disable-next-line vue/no-setup-props-destructure
const hide = ref((defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.video.isSensitive && defaultStore.state.nsfw !== 'ignore'));
@@ -112,6 +172,35 @@ function showMenu(ev: MouseEvent) {
menu = [
// TODO: 再生キューに追加
{
+ type: 'switch',
+ text: i18n.ts._mediaControls.loop,
+ icon: 'ti ti-repeat',
+ ref: loop,
+ },
+ {
+ type: 'radio',
+ text: i18n.ts._mediaControls.playbackRate,
+ icon: 'ti ti-clock-play',
+ ref: speed,
+ options: {
+ '0.25x': 0.25,
+ '0.5x': 0.5,
+ '0.75x': 0.75,
+ '1.0x': 1,
+ '1.25x': 1.25,
+ '1.5x': 1.5,
+ '2.0x': 2,
+ },
+ },
+ ...(document.pictureInPictureEnabled ? [{
+ text: i18n.ts._mediaControls.pip,
+ icon: 'ti ti-picture-in-picture',
+ action: togglePictureInPicture,
+ }] : []),
+ {
+ type: 'divider',
+ },
+ {
text: i18n.ts.hide,
icon: 'ti ti-eye-off',
action: () => {
@@ -122,8 +211,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 +218,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',
@@ -177,6 +275,8 @@ const rangePercent = computed({
},
});
const volume = ref(.25);
+const speed = ref(1);
+const loop = ref(false); // TODO: ドライブファイルのフラグに置き換える
const bufferedEnd = ref(0);
const bufferedDataRatio = computed(() => {
if (!videoEl.value) return 0;
@@ -234,6 +334,16 @@ function toggleFullscreen() {
}
}
+function togglePictureInPicture() {
+ if (videoEl.value) {
+ if (document.pictureInPictureElement) {
+ document.exitPictureInPicture();
+ } else {
+ videoEl.value.requestPictureInPicture();
+ }
+ }
+}
+
function toggleMute() {
if (volume.value === 0) {
volume.value = .25;
@@ -243,6 +353,7 @@ function toggleMute() {
}
let onceInit = false;
+let mediaTickFrameId: number | null = null;
let stopVideoElWatch: () => void;
function init() {
@@ -262,8 +373,12 @@ function init() {
}
elapsedTimeMs.value = videoEl.value.currentTime * 1000;
+
+ if (videoEl.value.loop !== loop.value) {
+ loop.value = videoEl.value.loop;
+ }
}
- window.requestAnimationFrame(updateMediaTick);
+ mediaTickFrameId = window.requestAnimationFrame(updateMediaTick);
}
updateMediaTick();
@@ -307,6 +422,14 @@ watch(volume, (to) => {
if (videoEl.value) videoEl.value.volume = to;
});
+watch(speed, (to) => {
+ if (videoEl.value) videoEl.value.playbackRate = to;
+});
+
+watch(loop, (to) => {
+ if (videoEl.value) videoEl.value.loop = to;
+});
+
watch(hide, (to) => {
if (to && isFullscreen.value) {
document.exitFullscreen();
@@ -332,6 +455,10 @@ onDeactivated(() => {
hide.value = (defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.video.isSensitive && defaultStore.state.nsfw !== 'ignore');
stopVideoElWatch();
onceInit = false;
+ if (mediaTickFrameId) {
+ window.cancelAnimationFrame(mediaTickFrameId);
+ mediaTickFrameId = null;
+ }
});
</script>
@@ -340,6 +467,10 @@ onDeactivated(() => {
container-type: inline-size;
position: relative;
overflow: clip;
+
+ &:focus {
+ outline: none;
+ }
}
.sensitive {
@@ -403,7 +534,7 @@ onDeactivated(() => {
font: inherit;
color: inherit;
cursor: pointer;
- padding: 120px 0;
+ padding: 60px 0;
display: flex;
align-items: center;
justify-content: center;
@@ -427,7 +558,6 @@ onDeactivated(() => {
display: block;
height: 100%;
width: 100%;
- pointer-events: none;
}
.videoOverlayPlayButton {
diff --git a/packages/frontend/src/components/MkMention.vue b/packages/frontend/src/components/MkMention.vue
index e6e8711f67..bfb49a416e 100644
--- a/packages/frontend/src/components/MkMention.vue
+++ b/packages/frontend/src/components/MkMention.vue
@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
-<MkA v-user-preview="canonical" :class="[$style.root, { [$style.isMe]: isMe }]" :to="url" :style="{ background: bgCss }">
+<MkA v-user-preview="canonical" :class="[$style.root, { [$style.isMe]: isMe }]" :to="url" :style="{ background: bgCss }" :behavior="navigationBehavior">
<img :class="$style.icon" :src="avatarUrl" alt="">
<span>
<span>@{{ username }}</span>
@@ -21,10 +21,12 @@ import { host as localHost } from '@/config.js';
import { $i } from '@/account.js';
import { defaultStore } from '@/store.js';
import { getStaticImageUrl } from '@/scripts/media-proxy.js';
+import { MkABehavior } from '@/components/global/MkA.vue';
const props = defineProps<{
username: string;
host: string;
+ navigationBehavior?: MkABehavior;
}>();
const canonical = props.host === localHost ? `@${props.username}` : `@${props.username}@${toUnicode(props.host)}`;
diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue
index faed6416d0..d91239b9e2 100644
--- a/packages/frontend/src/components/MkMenu.vue
+++ b/packages/frontend/src/components/MkMenu.vue
@@ -42,9 +42,26 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</button>
<button v-else-if="item.type === 'switch'" role="menuitemcheckbox" :tabindex="i" class="_button" :class="[$style.item, $style.switch, { [$style.switchDisabled]: item.disabled } ]" @click="switchItem(item)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
- <MkSwitchButton :class="$style.switchButton" :checked="item.ref" :disabled="item.disabled" @toggle="switchItem(item)"/>
+ <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
+ <MkSwitchButton v-else :class="$style.switchButton" :checked="item.ref" :disabled="item.disabled" @toggle="switchItem(item)"/>
+ <div :class="$style.item_content">
+ <span :class="[$style.item_content_text, { [$style.switchText]: !item.icon }]">{{ item.text }}</span>
+ <MkSwitchButton v-if="item.icon" :class="[$style.switchButton, $style.caret]" :checked="item.ref" :disabled="item.disabled" @toggle="switchItem(item)"/>
+ </div>
+ </button>
+ <button v-else-if="item.type === 'radio'" class="_button" role="menuitem" :tabindex="i" :class="[$style.item, $style.parent, { [$style.childShowing]: childShowingItem === item }]" @mouseenter="preferClick ? null : showRadioOptions(item, $event)" @click="!preferClick ? null : showRadioOptions(item, $event)">
+ <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]" style="pointer-events: none;"></i>
+ <div :class="$style.item_content">
+ <span :class="$style.item_content_text" style="pointer-events: none;">{{ item.text }}</span>
+ <span :class="$style.caret" style="pointer-events: none;"><i class="ti ti-chevron-right ti-fw"></i></span>
+ </div>
+ </button>
+ <button v-else-if="item.type === 'radioOption'" :tabindex="i" class="_button" role="menuitem" :class="[$style.item, { [$style.radioActive]: item.active }]" @click="clicked(item.action, $event, false)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
+ <div :class="$style.icon">
+ <span :class="[$style.radio, { [$style.radioChecked]: item.active }]"></span>
+ </div>
<div :class="$style.item_content">
- <span :class="[$style.item_content_text, $style.switchText]">{{ item.text }}</span>
+ <span :class="$style.item_content_text">{{ item.text }}</span>
</div>
</button>
<button v-else-if="item.type === 'parent'" class="_button" role="menuitem" :tabindex="i" :class="[$style.item, $style.parent, { [$style.childShowing]: childShowingItem === item }]" @mouseenter="preferClick ? null : showChildren(item, $event)" @click="!preferClick ? null : showChildren(item, $event)">
@@ -77,7 +94,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { ComputedRef, computed, defineAsyncComponent, isRef, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
import { focusPrev, focusNext } from '@/scripts/focus.js';
import MkSwitchButton from '@/components/MkSwitch.button.vue';
-import { MenuItem, InnerMenuItem, MenuPending, MenuAction, MenuSwitch, MenuParent } from '@/types/menu.js';
+import { MenuItem, InnerMenuItem, MenuPending, MenuAction, MenuSwitch, MenuRadio, MenuRadioOption, MenuParent } from '@/types/menu.js';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { isTouchUsing } from '@/scripts/touch.js';
@@ -168,6 +185,31 @@ function onItemMouseLeave(item) {
if (childCloseTimer) window.clearTimeout(childCloseTimer);
}
+async function showRadioOptions(item: MenuRadio, ev: MouseEvent) {
+ const children: MenuItem[] = Object.keys(item.options).map<MenuRadioOption>(key => {
+ const value = item.options[key];
+ return {
+ type: 'radioOption',
+ text: key,
+ action: () => {
+ item.ref = value;
+ },
+ active: computed(() => item.ref === value),
+ };
+ });
+
+ if (props.asDrawer) {
+ os.popupMenu(children, ev.currentTarget ?? ev.target).finally(() => {
+ emit('close');
+ });
+ emit('hide');
+ } else {
+ childTarget.value = (ev.currentTarget ?? ev.target) as HTMLElement;
+ childMenu.value = children;
+ childShowingItem.value = item;
+ }
+}
+
async function showChildren(item: MenuParent, ev: MouseEvent) {
const children: MenuItem[] = await (async () => {
if (childrenCache.has(item)) {
@@ -196,8 +238,10 @@ async function showChildren(item: MenuParent, ev: MouseEvent) {
}
}
-function clicked(fn: MenuAction, ev: MouseEvent) {
+function clicked(fn: MenuAction, ev: MouseEvent, doClose = true) {
fn(ev);
+
+ if (!doClose) return;
close(true);
}
@@ -350,6 +394,15 @@ onBeforeUnmount(() => {
}
}
+ &.radioActive {
+ color: var(--accent) !important;
+ opacity: 1;
+
+ &:before {
+ background-color: var(--accentedBg) !important;
+ }
+ }
+
&:not(:active):focus-visible {
box-shadow: 0 0 0 2px var(--focus) inset;
}
@@ -417,11 +470,11 @@ onBeforeUnmount(() => {
.switchButton {
margin-left: -2px;
+ --height: 1.35em;
}
.switchText {
margin-left: 8px;
- margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
}
@@ -461,4 +514,32 @@ onBeforeUnmount(() => {
margin: 8px 0;
border-top: solid 0.5px var(--divider);
}
+
+.radio {
+ display: inline-block;
+ position: relative;
+ width: 1em;
+ height: 1em;
+ vertical-align: -.125em;
+ border-radius: 50%;
+ border: solid 2px var(--divider);
+ background-color: var(--panel);
+
+ &.radioChecked {
+ border-color: var(--accent);
+
+ &::after {
+ content: "";
+ display: block;
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ width: 50%;
+ height: 50%;
+ border-radius: 50%;
+ background-color: var(--accent);
+ }
+ }
+}
</style>
diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue
index 40e67fb4e0..9e69ab2207 100644
--- a/packages/frontend/src/components/MkModal.vue
+++ b/packages/frontend/src/components/MkModal.vue
@@ -175,8 +175,8 @@ const align = () => {
let left;
let top;
- const x = srcRect.left + (fixed.value ? 0 : window.pageXOffset);
- const y = srcRect.top + (fixed.value ? 0 : window.pageYOffset);
+ const x = srcRect.left + (fixed.value ? 0 : window.scrollX);
+ const y = srcRect.top + (fixed.value ? 0 : window.scrollY);
if (props.anchor.x === 'center') {
left = x + (props.src.offsetWidth / 2) - (width / 2);
@@ -220,24 +220,24 @@ const align = () => {
}
} else {
// 画面から横にはみ出る場合
- if (left + width - window.pageXOffset > (window.innerWidth - SCROLLBAR_THICKNESS)) {
- left = (window.innerWidth - SCROLLBAR_THICKNESS) - width + window.pageXOffset - 1;
+ if (left + width - window.scrollX > (window.innerWidth - SCROLLBAR_THICKNESS)) {
+ left = (window.innerWidth - SCROLLBAR_THICKNESS) - width + window.scrollX - 1;
}
- const underSpace = ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN) - (top - window.pageYOffset);
+ const underSpace = ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN) - (top - window.scrollY);
const upperSpace = (srcRect.top - MARGIN);
// 画面から縦にはみ出る場合
- if (top + height - window.pageYOffset > ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN)) {
+ if (top + height - window.scrollY > ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN)) {
if (props.noOverlap && props.anchor.x === 'center') {
if (underSpace >= (upperSpace / 3)) {
maxHeight.value = underSpace;
} else {
maxHeight.value = upperSpace;
- top = window.pageYOffset + ((upperSpace + MARGIN) - height);
+ top = window.scrollY + ((upperSpace + MARGIN) - height);
}
} else {
- top = ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN) - height + window.pageYOffset - 1;
+ top = ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN) - height + window.scrollY - 1;
}
} else {
maxHeight.value = underSpace;
@@ -255,15 +255,15 @@ const align = () => {
let transformOriginX = 'center';
let transformOriginY = 'center';
- if (top >= srcRect.top + props.src.offsetHeight + (fixed.value ? 0 : window.pageYOffset)) {
+ if (top >= srcRect.top + props.src.offsetHeight + (fixed.value ? 0 : window.scrollY)) {
transformOriginY = 'top';
- } else if ((top + height) <= srcRect.top + (fixed.value ? 0 : window.pageYOffset)) {
+ } else if ((top + height) <= srcRect.top + (fixed.value ? 0 : window.scrollY)) {
transformOriginY = 'bottom';
}
- if (left >= srcRect.left + props.src.offsetWidth + (fixed.value ? 0 : window.pageXOffset)) {
+ if (left >= srcRect.left + props.src.offsetWidth + (fixed.value ? 0 : window.scrollX)) {
transformOriginX = 'left';
- } else if ((left + width) <= srcRect.left + (fixed.value ? 0 : window.pageXOffset)) {
+ } else if ((left + width) <= srcRect.left + (fixed.value ? 0 : window.scrollX)) {
transformOriginX = 'right';
}
@@ -276,8 +276,11 @@ const align = () => {
const onOpened = () => {
emit('opened');
+ // NOTE: Chromatic テストの際に undefined になる場合がある
+ if (content.value == null) return;
+
// モーダルコンテンツにマウスボタンが押され、コンテンツ外でマウスボタンが離されたときにモーダルバックグラウンドクリックと判定させないためにマウスイベントを監視しフラグ管理する
- const el = content.value!.children[0];
+ const el = content.value.children[0];
el.addEventListener('mousedown', ev => {
contentClicking = true;
window.addEventListener('mouseup', ev => {
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index 03a283cab3..22b1691a86 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -82,7 +82,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkMediaList :mediaList="appearNote.files"/>
</div>
<MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll"/>
- <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview"/>
+ <div v-if="isEnabledUrlPreview">
+ <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview"/>
+ </div>
<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
<button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click="collapsed = false">
<span :class="$style.collapsedLabel">{{ i18n.ts.showMore }}</span>
@@ -93,15 +95,15 @@ 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>
+ <MkA :to="`/notes/${appearNote.id}/reactions`" :class="[$style.reactionOmitted]">{{ i18n.ts.more }}</MkA>
</template>
</MkReactionsViewer>
<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 +113,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>
@@ -165,6 +167,7 @@ import MkNoteSub from '@/components/MkNoteSub.vue';
import MkNoteHeader from '@/components/MkNoteHeader.vue';
import MkNoteSimple from '@/components/MkNoteSimple.vue';
import MkReactionsViewer from '@/components/MkReactionsViewer.vue';
+import MkReactionsViewerDetails from '@/components/MkReactionsViewer.details.vue';
import MkMediaList from '@/components/MkMediaList.vue';
import MkCwButton from '@/components/MkCwButton.vue';
import MkPoll from '@/components/MkPoll.vue';
@@ -175,9 +178,10 @@ 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';
+import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js';
import { defaultStore, noteViewInterruptors } from '@/store.js';
import { reactionPicker } from '@/scripts/reaction-picker.js';
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js';
@@ -193,6 +197,7 @@ import { MenuItem } from '@/types/menu.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
import { shouldCollapsed } from '@/scripts/collapsed.js';
+import { isEnabledUrlPreview } from '@/instance.js';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
@@ -237,6 +242,7 @@ if (noteViewInterruptors.length > 0) {
const isRenote = (
note.value.renote != null &&
+ note.value.reply == null &&
note.value.text == null &&
note.value.cw == null &&
note.value.fileIds && note.value.fileIds.length === 0 &&
@@ -267,7 +273,7 @@ const renoteCollapsed = ref(
defaultStore.state.collapseRenotes && isRenote && (
($i && ($i.id === note.value.userId || $i.id === appearNote.value.userId)) || // `||` must be `||`! See https://github.com/misskey-dev/misskey/issues/13131
(appearNote.value.myReaction != null)
- )
+ ),
);
/* Overload FunctionにLintが対応していないのでコメントアウト
@@ -336,6 +342,28 @@ if (!props.mock) {
targetElement: renoteButton.value,
}, {}, 'closed');
});
+
+ if (appearNote.value.reactionAcceptance === 'likeOnly') {
+ useTooltip(reactButton, async (showing) => {
+ const reactions = await misskeyApiGet('notes/reactions', {
+ noteId: appearNote.value.id,
+ limit: 10,
+ _cacheKey_: appearNote.value.reactionCount,
+ });
+
+ const users = reactions.map(x => x.user);
+
+ if (users.length < 1) return;
+
+ os.popup(MkReactionsViewerDetails, {
+ showing,
+ reaction: '❤️',
+ users,
+ count: appearNote.value.reactionCount,
+ targetElement: reactButton.value!,
+ }, {}, 'closed');
+ });
+ }
}
function renote(viaKeyboard = false) {
@@ -420,6 +448,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;
@@ -985,9 +1021,8 @@ function emitUpdReaction(emoji: string, delta: number) {
.reactionOmitted {
display: inline-block;
- height: 32px;
- margin: 2px;
- padding: 0 6px;
+ margin-left: 8px;
opacity: .8;
+ font-size: 95%;
}
</style>
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index e3ef14120f..ed1c0a9e96 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -68,7 +68,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.noteContent">
<p v-if="appearNote.cw != null" :class="$style.cw">
<Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'"/>
- <MkCwButton v-model="showContent" :text="appearNote.text" :files="appearNote.files" :poll="appearNote.poll"/>
+ <MkCwButton v-model="showContent" :text="appearNote.text" :renote="appearNote.renote" :files="appearNote.files" :poll="appearNote.poll"/>
</p>
<div v-show="appearNote.cw == null || showContent">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
@@ -95,7 +95,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkMediaList :mediaList="appearNote.files"/>
</div>
<MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll"/>
- <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" style="margin-top: 6px;"/>
+ <div v-if="isEnabledUrlPreview">
+ <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" style="margin-top: 6px;"/>
+ </div>
<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
</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>
@@ -106,10 +108,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 +121,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>
@@ -199,6 +201,7 @@ import * as Misskey from 'misskey-js';
import MkNoteSub from '@/components/MkNoteSub.vue';
import MkNoteSimple from '@/components/MkNoteSimple.vue';
import MkReactionsViewer from '@/components/MkReactionsViewer.vue';
+import MkReactionsViewerDetails from '@/components/MkReactionsViewer.details.vue';
import MkMediaList from '@/components/MkMediaList.vue';
import MkCwButton from '@/components/MkCwButton.vue';
import MkPoll from '@/components/MkPoll.vue';
@@ -209,8 +212,9 @@ 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 { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js';
import * as sound from '@/scripts/sound.js';
import { defaultStore, noteViewInterruptors } from '@/store.js';
import { reactionPicker } from '@/scripts/reaction-picker.js';
@@ -228,10 +232,14 @@ import MkUserCardMini from '@/components/MkUserCardMini.vue';
import MkPagination, { type Paging } from '@/components/MkPagination.vue';
import MkReactionIcon from '@/components/MkReactionIcon.vue';
import MkButton from '@/components/MkButton.vue';
+import { isEnabledUrlPreview } from '@/instance.js';
-const props = defineProps<{
+const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
-}>();
+ initialTab: string;
+}>(), {
+ initialTab: 'replies',
+});
const inChannel = inject('inChannel', null);
@@ -258,7 +266,9 @@ if (noteViewInterruptors.length > 0) {
const isRenote = (
note.value.renote != null &&
+ note.value.reply == null &&
note.value.text == null &&
+ note.value.cw == null &&
note.value.fileIds && note.value.fileIds.length === 0 &&
note.value.poll == null
);
@@ -299,7 +309,7 @@ provide('react', (reaction: string) => {
});
});
-const tab = ref('replies');
+const tab = ref(props.initialTab);
const reactionTabType = ref<string | null>(null);
const renotesPagination = computed<Paging>(() => ({
@@ -344,6 +354,28 @@ useTooltip(renoteButton, async (showing) => {
}, {}, 'closed');
});
+if (appearNote.value.reactionAcceptance === 'likeOnly') {
+ useTooltip(reactButton, async (showing) => {
+ const reactions = await misskeyApiGet('notes/reactions', {
+ noteId: appearNote.value.id,
+ limit: 10,
+ _cacheKey_: appearNote.value.reactionCount,
+ });
+
+ const users = reactions.map(x => x.user);
+
+ if (users.length < 1) return;
+
+ os.popup(MkReactionsViewerDetails, {
+ showing,
+ reaction: '❤️',
+ users,
+ count: appearNote.value.reactionCount,
+ targetElement: reactButton.value!,
+ }, {}, 'closed');
+ });
+}
+
function renote(viaKeyboard = false) {
pleaseLogin();
showMovedDialog();
@@ -401,14 +433,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;
diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue
index 322b9400be..73cd7cd5b3 100644
--- a/packages/frontend/src/components/MkNotification.vue
+++ b/packages/frontend/src/components/MkNotification.vue
@@ -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,7 +58,8 @@ 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'">{{ i18n.tsx._notification.reactedBySomeUsers({ n: notification.reactions.length }) }}</span>
+ <span v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'">{{ i18n.tsx._notification.likedBySomeUsers({ n: getActualReactedUsersCount(notification) }) }}</span>
+ <span v-else-if="notification.type === 'reaction:grouped'">{{ i18n.tsx._notification.reactedBySomeUsers({ n: getActualReactedUsersCount(notification) }) }}</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>
<MkTime v-if="withTime" :time="notification.createdAt" :class="$style.headerTime"/>
@@ -70,7 +72,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkA>
<MkA v-else-if="notification.type === 'renote' || notification.type === 'renote:grouped'" :class="$style.text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note.renote)">
<i class="ti ti-quote" :class="$style.quote"></i>
- <Mfm :text="getNoteSummary(notification.note.renote)" :plain="true" :nowrap="true" :author="notification.note.renote.user"/>
+ <Mfm :text="getNoteSummary(notification.note.renote)" :plain="true" :nowrap="true" :author="notification.note.renote?.user"/>
<i class="ti ti-quote" :class="$style.quote"></i>
</MkA>
<MkA v-else-if="notification.type === 'reply'" :class="$style.text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
@@ -172,6 +174,11 @@ const rejectFollowRequest = () => {
followRequestDone.value = true;
misskeyApi('following/requests/reject', { userId: props.notification.user.id });
};
+
+function getActualReactedUsersCount(notification: Misskey.entities.Notification) {
+ if (notification.type !== 'reaction:grouped') return 0;
+ return new Set(notification.reactions.map((reaction) => reaction.user.id)).size;
+}
</script>
<style lang="scss" module>
@@ -201,6 +208,7 @@ const rejectFollowRequest = () => {
}
.icon_reactionGroup,
+.icon_reactionGroupHeart,
.icon_renoteGroup {
display: grid;
align-items: center;
@@ -213,11 +221,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 +258,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;
}
diff --git a/packages/frontend/src/components/MkPasswordDialog.vue b/packages/frontend/src/components/MkPasswordDialog.vue
index c49526d8e2..e749725fea 100644
--- a/packages/frontend/src/components/MkPasswordDialog.vue
+++ b/packages/frontend/src/components/MkPasswordDialog.vue
@@ -19,18 +19,21 @@ SPDX-License-Identifier: AGPL-3.0-only
<div style="margin-top: 16px;">{{ i18n.ts.authenticationRequiredToContinue }}</div>
</div>
- <div class="_gaps">
- <MkInput ref="passwordInput" v-model="password" :placeholder="i18n.ts.password" type="password" autocomplete="current-password webauthn" :withPasswordToggle="true">
- <template #prefix><i class="ti ti-password"></i></template>
- </MkInput>
+ <form @submit.prevent="done">
+ <div class="_gaps">
+ <MkInput ref="passwordInput" v-model="password" :placeholder="i18n.ts.password" type="password" autocomplete="current-password webauthn" required :withPasswordToggle="true">
+ <template #prefix><i class="ti ti-password"></i></template>
+ </MkInput>
- <MkInput v-if="$i.twoFactorEnabled" v-model="token" type="text" pattern="^([0-9]{6}|[A-Z0-9]{32})$" autocomplete="one-time-code" :spellcheck="false">
- <template #label>{{ i18n.ts.token }} ({{ i18n.ts['2fa'] }})</template>
- <template #prefix><i class="ti ti-123"></i></template>
- </MkInput>
+ <MkInput v-if="$i.twoFactorEnabled" v-model="token" type="text" :pattern="isBackupCode ? '^[A-Z0-9]{32}$' :'^[0-9]{6}$'" autocomplete="one-time-code" required :spellcheck="false" :inputmode="isBackupCode ? undefined : 'numeric'">
+ <template #label>{{ i18n.ts.token }} ({{ i18n.ts['2fa'] }})</template>
+ <template #prefix><i v-if="isBackupCode" class="ti ti-key"></i><i v-else class="ti ti-123"></i></template>
+ <template #caption><button class="_textButton" type="button" @click="isBackupCode = !isBackupCode">{{ isBackupCode ? i18n.ts.useTotp : i18n.ts.useBackupCode }}</button></template>
+ </MkInput>
- <MkButton :disabled="(password ?? '') == '' || ($i.twoFactorEnabled && (token ?? '') == '')" primary rounded style="margin: 0 auto;" @click="done"><i class="ti ti-lock-open"></i> {{ i18n.ts.continue }}</MkButton>
- </div>
+ <MkButton :disabled="(password ?? '') == '' || ($i.twoFactorEnabled && (token ?? '') == '')" type="submit" primary rounded style="margin: 0 auto;"><i class="ti ti-lock-open"></i> {{ i18n.ts.continue }}</MkButton>
+ </div>
+ </form>
</MkSpacer>
</MkModalWindow>
</template>
@@ -54,6 +57,7 @@ const emit = defineEmits<{
const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
const passwordInput = shallowRef<InstanceType<typeof MkInput>>();
const password = ref('');
+const isBackupCode = ref(false);
const token = ref<string | null>(null);
function onClose() {
@@ -61,7 +65,7 @@ function onClose() {
if (dialog.value) dialog.value.close();
}
-function done(res) {
+function done() {
emit('done', { password: password.value, token: token.value });
if (dialog.value) dialog.value.close();
}
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index e03faeaf55..1df9007681 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -156,6 +156,7 @@ const props = withDefaults(defineProps<{
initialVisibleUsers: () => [],
autofocus: true,
mock: false,
+ initialLocalOnly: undefined,
});
provide('mock', props.mock);
@@ -185,11 +186,11 @@ watch(showPreview, () => defaultStore.set('showPreview', showPreview.value));
const showAddMfmFunction = ref(defaultStore.state.enableQuickAddMfmFunction);
watch(showAddMfmFunction, () => defaultStore.set('enableQuickAddMfmFunction', showAddMfmFunction.value));
const cw = ref<string | null>(props.initialCw ?? null);
-const localOnly = ref<boolean>(props.initialLocalOnly ?? defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly);
-const visibility = ref(props.initialVisibility ?? (defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility) as typeof Misskey.noteVisibilities[number]);
+const localOnly = ref(props.initialLocalOnly ?? (defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly));
+const visibility = ref(props.initialVisibility ?? (defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility));
const visibleUsers = ref<Misskey.entities.UserDetailed[]>([]);
if (props.initialVisibleUsers) {
- props.initialVisibleUsers.forEach(pushVisibleUser);
+ props.initialVisibleUsers.forEach(u => pushVisibleUser(u));
}
const reactionAcceptance = ref(defaultStore.state.reactionAcceptance);
const autocomplete = ref(null);
@@ -253,7 +254,13 @@ const maxTextLength = computed((): number => {
const canPost = computed((): boolean => {
return !props.mock && !posting.value && !posted.value &&
- (1 <= textLength.value || 1 <= files.value.length || !!poll.value || !!props.renote) &&
+ (
+ 1 <= textLength.value ||
+ 1 <= files.value.length ||
+ poll.value != null ||
+ props.renote != null ||
+ (props.reply != null && quoteId.value != null)
+ ) &&
(textLength.value <= maxTextLength.value) &&
(!poll.value || poll.value.choices.length >= 2);
});
@@ -329,7 +336,7 @@ if (props.reply && ['home', 'followers', 'specified'].includes(props.reply.visib
misskeyApi('users/show', {
userIds: props.reply.visibleUserIds.filter(uid => uid !== $i.id && uid !== props.reply?.userId),
}).then(users => {
- users.forEach(pushVisibleUser);
+ users.forEach(u => pushVisibleUser(u));
});
}
@@ -382,7 +389,7 @@ function addMissingMention() {
for (const x of extractMentions(ast)) {
if (!visibleUsers.value.some(u => (u.username === x.username) && (u.host === x.host))) {
misskeyApi('users/show', { username: x.username, host: x.host }).then(user => {
- visibleUsers.value.push(user);
+ pushVisibleUser(user);
});
}
}
@@ -512,6 +519,9 @@ async function toggleLocalOnly() {
}
localOnly.value = !localOnly.value;
+ if (defaultStore.state.rememberNoteVisibility) {
+ defaultStore.set('localOnly', localOnly.value);
+ }
}
async function toggleReactionAcceptance() {
@@ -602,6 +612,23 @@ async function onPaste(ev: ClipboardEvent) {
quoteId.value = paste.substring(url.length).match(/^\/notes\/(.+?)\/?$/)?.[1] ?? null;
});
}
+
+ if (paste.length > 1000) {
+ ev.preventDefault();
+ os.confirm({
+ type: 'info',
+ text: i18n.ts.attachAsFileQuestion,
+ }).then(({ canceled }) => {
+ if (canceled) {
+ insertTextAtCursor(textareaEl.value, paste);
+ return;
+ }
+
+ const fileName = formatTimeString(new Date(), defaultStore.state.pastedFileName).replace(/{{number}}/g, "0");
+ const file = new File([paste], `${fileName}.txt`, { type: "text/plain" });
+ upload(file, `${fileName}.txt`);
+ });
+ }
}
function onDragover(ev) {
@@ -673,6 +700,7 @@ function saveDraft() {
localOnly: localOnly.value,
files: files.value,
poll: poll.value,
+ visibleUserIds: visibility.value === 'specified' ? visibleUsers.value.map(x => x.id) : undefined,
},
};
@@ -954,6 +982,11 @@ onMounted(() => {
if (draft.data.poll) {
poll.value = draft.data.poll;
}
+ if (draft.data.visibleUserIds) {
+ misskeyApi('users/show', { userIds: draft.data.visibleUserIds }).then(users => {
+ users.forEach(u => pushVisibleUser(u));
+ });
+ }
}
}
diff --git a/packages/frontend/src/components/MkPostFormDialog.vue b/packages/frontend/src/components/MkPostFormDialog.vue
index 6331dfed29..ac37cb31bc 100644
--- a/packages/frontend/src/components/MkPostFormDialog.vue
+++ b/packages/frontend/src/components/MkPostFormDialog.vue
@@ -15,7 +15,7 @@ import * as Misskey from 'misskey-js';
import MkModal from '@/components/MkModal.vue';
import MkPostForm from '@/components/MkPostForm.vue';
-const props = defineProps<{
+const props = withDefaults(defineProps<{
reply?: Misskey.entities.Note;
renote?: Misskey.entities.Note;
channel?: any; // TODO
@@ -31,7 +31,9 @@ const props = defineProps<{
instant?: boolean;
fixed?: boolean;
autofocus?: boolean;
-}>();
+}>(), {
+ initialLocalOnly: undefined,
+});
const emit = defineEmits<{
(ev: 'closed'): void;
diff --git a/packages/frontend/src/components/MkReactionsViewer.vue b/packages/frontend/src/components/MkReactionsViewer.vue
index 1bd37d842b..63b202f9f3 100644
--- a/packages/frontend/src/components/MkReactionsViewer.vue
+++ b/packages/frontend/src/components/MkReactionsViewer.vue
@@ -100,6 +100,9 @@ watch([() => props.note.reactions, () => props.maxNumber], ([newSource, maxNumbe
}
.root {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
margin: 4px -2px 0 -2px;
&:empty {
diff --git a/packages/frontend/src/components/MkSignin.vue b/packages/frontend/src/components/MkSignin.vue
index 852af01b5a..970aff825d 100644
--- a/packages/frontend/src/components/MkSignin.vue
+++ b/packages/frontend/src/components/MkSignin.vue
@@ -31,15 +31,15 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="user && user.securityKeys" class="or-hr">
<p class="or-msg">{{ i18n.ts.or }}</p>
</div>
- <div class="twofa-group totp-group">
- <p style="margin-bottom:0;">{{ i18n.ts['2fa'] }}</p>
+ <div class="twofa-group totp-group _gaps">
<MkInput v-if="user && user.usePasswordLessLogin" v-model="password" type="password" autocomplete="current-password" :withPasswordToggle="true" required>
<template #label>{{ i18n.ts.password }}</template>
<template #prefix><i class="ti ti-lock"></i></template>
</MkInput>
- <MkInput v-model="token" type="text" pattern="^([0-9]{6}|[A-Z0-9]{32})$" autocomplete="one-time-code" :spellcheck="false" required>
- <template #label>{{ i18n.ts.token }}</template>
- <template #prefix><i class="ti ti-123"></i></template>
+ <MkInput v-model="token" type="text" :pattern="isBackupCode ? '^[A-Z0-9]{32}$' :'^[0-9]{6}$'" autocomplete="one-time-code" required :spellcheck="false" :inputmode="isBackupCode ? undefined : 'numeric'">
+ <template #label>{{ i18n.ts.token }} ({{ i18n.ts['2fa'] }})</template>
+ <template #prefix><i v-if="isBackupCode" class="ti ti-key"></i><i v-else class="ti ti-123"></i></template>
+ <template #caption><button class="_textButton" type="button" @click="isBackupCode = !isBackupCode">{{ isBackupCode ? i18n.ts.useTotp : i18n.ts.useBackupCode }}</button></template>
</MkInput>
<MkButton type="submit" :disabled="signing" large primary rounded style="margin: 0 auto;">{{ signing ? i18n.ts.loggingIn : i18n.ts.login }}</MkButton>
</div>
@@ -70,6 +70,7 @@ const password = ref('');
const token = ref('');
const host = ref(toUnicode(configHost));
const totpLogin = ref(false);
+const isBackupCode = ref(false);
const queryingKey = ref(false);
const credentialRequest = ref<CredentialRequestOptions | null>(null);
diff --git a/packages/frontend/src/components/MkSignupDialog.rules.stories.impl.ts b/packages/frontend/src/components/MkSignupDialog.rules.stories.impl.ts
index fcd1ffde3e..9df3ec0c30 100644
--- a/packages/frontend/src/components/MkSignupDialog.rules.stories.impl.ts
+++ b/packages/frontend/src/components/MkSignupDialog.rules.stories.impl.ts
@@ -51,13 +51,16 @@ export const Empty = {
expect(buttons.at(-1)).toBeEnabled();
},
args: {
+ // @ts-expect-error serverRules is for test
serverRules: [],
tosUrl: null,
},
decorators: [
(_, context) => ({
setup() {
+ // @ts-expect-error serverRules is for test
instance.serverRules = context.args.serverRules;
+ // @ts-expect-error tosUrl is for test
instance.tosUrl = context.args.tosUrl;
onBeforeUnmount(() => {
// FIXME: 呼び出されない
@@ -76,6 +79,7 @@ export const ServerRulesOnly = {
...Empty,
args: {
...Empty.args,
+ // @ts-expect-error serverRules is for test
serverRules: [
'ルール',
],
@@ -85,6 +89,7 @@ export const TOSOnly = {
...Empty,
args: {
...Empty.args,
+ // @ts-expect-error tosUrl is for test
tosUrl: 'https://example.com/tos',
},
} satisfies StoryObj<typeof MkSignupServerRules>;
@@ -92,6 +97,7 @@ export const ServerRulesAndTOS = {
...Empty,
args: {
...Empty.args,
+ // @ts-expect-error serverRules is for test
serverRules: ServerRulesOnly.args.serverRules,
tosUrl: TOSOnly.args.tosUrl,
},
diff --git a/packages/frontend/src/components/MkSwitch.button.vue b/packages/frontend/src/components/MkSwitch.button.vue
index c95c933663..226908e221 100644
--- a/packages/frontend/src/components/MkSwitch.button.vue
+++ b/packages/frontend/src/components/MkSwitch.button.vue
@@ -41,13 +41,15 @@ const toggle = () => {
<style lang="scss" module>
.button {
+ --height: 21px;
+
position: relative;
display: inline-flex;
flex-shrink: 0;
margin: 0;
box-sizing: border-box;
- width: 32px;
- height: 23px;
+ width: calc(var(--height) * 1.6);
+ height: calc(var(--height) + 2px); // 枠線
outline: none;
background: var(--switchOffBg);
background-clip: content-box;
@@ -69,9 +71,10 @@ const toggle = () => {
.knob {
position: absolute;
+ box-sizing: border-box;
top: 3px;
- width: 15px;
- height: 15px;
+ width: calc(var(--height) - 6px);
+ height: calc(var(--height) - 6px);
border-radius: 999px;
transition: all 0.2s ease;
@@ -82,7 +85,7 @@ const toggle = () => {
}
.knobChecked {
- left: 12px;
+ left: calc(calc(100% - var(--height)) + 3px);
background: var(--switchOnFg);
}
</style>
diff --git a/packages/frontend/src/components/MkTutorialDialog.Note.vue b/packages/frontend/src/components/MkTutorialDialog.Note.vue
index f03a83293b..2a26d22dc2 100644
--- a/packages/frontend/src/components/MkTutorialDialog.Note.vue
+++ b/packages/frontend/src/components/MkTutorialDialog.Note.vue
@@ -63,6 +63,7 @@ const exampleNote = reactive<Misskey.entities.Note>({
reactionAcceptance: null,
renoteCount: 0,
repliesCount: 1,
+ reactionCount: 0,
reactions: {},
reactionEmojis: {},
fileIds: [],
diff --git a/packages/frontend/src/components/MkTutorialDialog.PostNote.vue b/packages/frontend/src/components/MkTutorialDialog.PostNote.vue
index 2b8c586dac..e1d88b5e5c 100644
--- a/packages/frontend/src/components/MkTutorialDialog.PostNote.vue
+++ b/packages/frontend/src/components/MkTutorialDialog.PostNote.vue
@@ -68,6 +68,7 @@ const exampleCWNote = reactive<Misskey.entities.Note>({
reactionAcceptance: null,
renoteCount: 0,
repliesCount: 1,
+ reactionCount: 0,
reactions: {},
reactionEmojis: {},
fileIds: [],
diff --git a/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue b/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue
index b17ec66461..7ae48dcd15 100644
--- a/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue
+++ b/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue
@@ -58,6 +58,7 @@ const exampleNote = reactive<Misskey.entities.Note>({
reactionAcceptance: null,
renoteCount: 0,
repliesCount: 1,
+ reactionCount: 0,
reactions: {},
reactionEmojis: {},
fileIds: ['0000000002'],
diff --git a/packages/frontend/src/components/MkUrlPreview.vue b/packages/frontend/src/components/MkUrlPreview.vue
index efc58b7e29..6954f1f6ff 100644
--- a/packages/frontend/src/components/MkUrlPreview.vue
+++ b/packages/frontend/src/components/MkUrlPreview.vue
@@ -152,15 +152,16 @@ requestUrl.hash = '';
window.fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${versatileLang}`)
.then(res => {
if (!res.ok) {
- fetching.value = false;
- unknownUrl.value = true;
- return;
+ if (_DEV_) {
+ console.warn(`[HTTP${res.status}] Failed to fetch url preview`);
+ }
+ return null;
}
return res.json();
})
- .then((info: SummalyResult) => {
- if (info.url == null) {
+ .then((info: SummalyResult | null) => {
+ if (!info || info.url == null) {
fetching.value = false;
unknownUrl.value = true;
return;
diff --git a/packages/frontend/src/components/MkUrlPreviewPopup.vue b/packages/frontend/src/components/MkUrlPreviewPopup.vue
index cf75064be7..e972973dba 100644
--- a/packages/frontend/src/components/MkUrlPreviewPopup.vue
+++ b/packages/frontend/src/components/MkUrlPreviewPopup.vue
@@ -33,8 +33,8 @@ const left = ref(0);
onMounted(() => {
const rect = props.source.getBoundingClientRect();
- const x = Math.max((rect.left + (props.source.offsetWidth / 2)) - (300 / 2), 6) + window.pageXOffset;
- const y = rect.top + props.source.offsetHeight + window.pageYOffset;
+ const x = Math.max((rect.left + (props.source.offsetWidth / 2)) - (300 / 2), 6) + window.scrollX;
+ const y = rect.top + props.source.offsetHeight + window.scrollY;
top.value = y;
left.value = x;
diff --git a/packages/frontend/src/components/MkUserPopup.vue b/packages/frontend/src/components/MkUserPopup.vue
index fb1a8f4fdc..41b27a1afb 100644
--- a/packages/frontend/src/components/MkUserPopup.vue
+++ b/packages/frontend/src/components/MkUserPopup.vue
@@ -106,8 +106,8 @@ onMounted(() => {
}
const rect = props.source.getBoundingClientRect();
- const x = ((rect.left + (props.source.offsetWidth / 2)) - (300 / 2)) + window.pageXOffset;
- const y = rect.top + props.source.offsetHeight + window.pageYOffset;
+ const x = ((rect.left + (props.source.offsetWidth / 2)) - (300 / 2)) + window.scrollX;
+ const y = rect.top + props.source.offsetHeight + window.scrollY;
top.value = y;
left.value = x;
diff --git a/packages/frontend/src/components/MkVisitorDashboard.vue b/packages/frontend/src/components/MkVisitorDashboard.vue
index be80baa774..f7963f9938 100644
--- a/packages/frontend/src/components/MkVisitorDashboard.vue
+++ b/packages/frontend/src/components/MkVisitorDashboard.vue
@@ -4,19 +4,19 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
-<div v-if="meta" :class="$style.root">
+<div v-if="instance" :class="$style.root">
<div :class="[$style.main, $style.panel]">
<img :src="instance.iconUrl || '/favicon.ico'" alt="" :class="$style.mainIcon"/>
<button class="_button _acrylic" :class="$style.mainMenu" @click="showMenu"><i class="ti ti-dots"></i></button>
<div :class="$style.mainFg">
<h1 :class="$style.mainTitle">
<!-- 背景色によってはロゴが見えなくなるのでとりあえず無効に -->
- <!-- <img class="logo" v-if="meta.logoImageUrl" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span> -->
+ <!-- <img class="logo" v-if="instance.logoImageUrl" :src="instance.logoImageUrl"><span v-else class="text">{{ instanceName }}</span> -->
<span>{{ instanceName }}</span>
</h1>
<div :class="$style.mainAbout">
<!-- eslint-disable-next-line vue/no-v-html -->
- <div v-html="meta.description || i18n.ts.headlineMisskey"></div>
+ <div v-html="instance.description || i18n.ts.headlineMisskey"></div>
</div>
<div v-if="instance.disableRegistration" :class="$style.mainWarn">
<MkInfo warn>{{ i18n.ts.invitationRequiredToRegister }}</MkInfo>
@@ -65,14 +65,10 @@ import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js';
import MkNumber from '@/components/MkNumber.vue';
import XActiveUsersChart from '@/components/MkVisitorDashboard.ActiveUsersChart.vue';
+import { openInstanceMenu } from '@/ui/_common_/common';
-const meta = ref<Misskey.entities.MetaResponse | null>(null);
const stats = ref<Misskey.entities.StatsResponse | null>(null);
-misskeyApi('meta', { detail: true }).then(_meta => {
- meta.value = _meta;
-});
-
misskeyApi('stats', {}).then((res) => {
stats.value = res;
});
@@ -90,43 +86,7 @@ function signup() {
}
function showMenu(ev) {
- os.popupMenu([{
- text: i18n.ts.instanceInfo,
- icon: 'ti ti-info-circle',
- action: () => {
- os.pageWindow('/about');
- },
- }, {
- text: i18n.ts.aboutMisskey,
- icon: 'ti ti-info-circle',
- action: () => {
- os.pageWindow('/about-misskey');
- },
- }, { type: 'divider' }, (instance.impressumUrl) ? {
- text: i18n.ts.impressum,
- icon: 'ti ti-file-invoice',
- action: () => {
- window.open(instance.impressumUrl!, '_blank', 'noopener');
- },
- } : undefined, (instance.tosUrl) ? {
- text: i18n.ts.termsOfService,
- icon: 'ti ti-notebook',
- action: () => {
- window.open(instance.tosUrl!, '_blank', 'noopener');
- },
- } : undefined, (instance.privacyPolicyUrl) ? {
- text: i18n.ts.privacyPolicy,
- icon: 'ti ti-shield-lock',
- action: () => {
- window.open(instance.privacyPolicyUrl!, '_blank', 'noopener');
- },
- } : undefined, (!instance.impressumUrl && !instance.tosUrl && !instance.privacyPolicyUrl) ? undefined : { type: 'divider' }, {
- text: i18n.ts.help,
- icon: 'ti ti-help-circle',
- action: () => {
- window.open('https://misskey-hub.net/docs/for-users/', '_blank', 'noopener');
- },
- }], ev.currentTarget ?? ev.target);
+ openInstanceMenu(ev);
}
function exploreOtherServers() {
diff --git a/packages/frontend/src/components/global/I18n.vue b/packages/frontend/src/components/global/I18n.vue
index 162aa2bcf8..6b7723e6ac 100644
--- a/packages/frontend/src/components/global/I18n.vue
+++ b/packages/frontend/src/components/global/I18n.vue
@@ -1,3 +1,8 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
<template>
<render/>
</template>
diff --git a/packages/frontend/src/components/global/MkA.vue b/packages/frontend/src/components/global/MkA.vue
index 61d7ac17d9..d1e9113c48 100644
--- a/packages/frontend/src/components/global/MkA.vue
+++ b/packages/frontend/src/components/global/MkA.vue
@@ -4,13 +4,17 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
-<a :href="to" :class="active ? activeClass : null" @click.prevent="nav" @contextmenu.prevent.stop="onContextmenu">
+<a ref="el" :href="to" :class="active ? activeClass : null" @click.prevent="nav" @contextmenu.prevent.stop="onContextmenu">
<slot></slot>
</a>
</template>
+<script lang="ts">
+export type MkABehavior = 'window' | 'browser' | null;
+</script>
+
<script lang="ts" setup>
-import { computed } from 'vue';
+import { computed, inject, shallowRef } from 'vue';
import * as os from '@/os.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
import { url } from '@/config.js';
@@ -20,12 +24,18 @@ import { useRouter } from '@/router/supplier.js';
const props = withDefaults(defineProps<{
to: string;
activeClass?: null | string;
- behavior?: null | 'window' | 'browser';
+ behavior?: MkABehavior;
}>(), {
activeClass: null,
behavior: null,
});
+const behavior = props.behavior ?? inject<MkABehavior>('linkNavigationBehavior', null);
+
+const el = shallowRef<HTMLElement>();
+
+defineExpose({ $el: el });
+
const router = useRouter();
const active = computed(() => {
@@ -76,15 +86,13 @@ function openWindow() {
}
function nav(ev: MouseEvent) {
- if (props.behavior === 'browser') {
+ if (behavior === 'browser') {
location.href = props.to;
return;
}
- if (props.behavior) {
- if (props.behavior === 'window') {
- return openWindow();
- }
+ if (behavior === 'window') {
+ return openWindow();
}
if (ev.shiftKey) {
diff --git a/packages/frontend/src/components/global/MkAd.stories.impl.ts b/packages/frontend/src/components/global/MkAd.stories.impl.ts
index f6cdc2bf23..aef26ab92d 100644
--- a/packages/frontend/src/components/global/MkAd.stories.impl.ts
+++ b/packages/frontend/src/components/global/MkAd.stories.impl.ts
@@ -4,11 +4,17 @@
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import { expect, userEvent, waitFor, within } from '@storybook/test';
import { StoryObj } from '@storybook/vue3';
import MkAd from './MkAd.vue';
+import { i18n } from '@/i18n.js';
let lock: Promise<undefined> | undefined;
+function sleep(ms: number) {
+ return new Promise(resolve => setTimeout(resolve, ms));
+}
+
const common = {
render(args) {
return {
@@ -30,7 +36,6 @@ const common = {
template: '<MkAd v-bind="props" />',
};
},
- /* FIXME: disabled because it still didn’t pass after applying #11267
async play({ canvasElement, args }) {
if (lock) {
console.warn('This test is unexpectedly running twice in parallel, fix it!');
@@ -42,9 +47,11 @@ const common = {
lock = new Promise(r => resolve = r);
try {
+ // NOTE: sleep しないと何故か落ちる
+ await sleep(100);
const canvas = within(canvasElement);
const a = canvas.getByRole<HTMLAnchorElement>('link');
- await expect(a.href).toMatch(/^https?:\/\/.*#test$/);
+ // await expect(a.href).toMatch(/^https?:\/\/.*#test$/);
const img = within(a).getByRole('img');
await expect(img).toBeInTheDocument();
let buttons = canvas.getAllByRole<HTMLButtonElement>('button');
@@ -52,13 +59,14 @@ const common = {
const i = buttons[0];
await expect(i).toBeInTheDocument();
await userEvent.click(i);
- await waitFor(() => expect(canvasElement).toHaveTextContent(i18n.ts._ad.back));
+ await expect(canvasElement).toHaveTextContent(i18n.ts._ad.back);
await expect(a).not.toBeInTheDocument();
await expect(i).not.toBeInTheDocument();
buttons = canvas.getAllByRole<HTMLButtonElement>('button');
- await expect(buttons).toHaveLength(args.__hasReduce ? 2 : 1);
- const reduce = args.__hasReduce ? buttons[0] : null;
- const back = buttons[args.__hasReduce ? 1 : 0];
+ const hasReduceFrequency = args.specify?.ratio !== 0;
+ await expect(buttons).toHaveLength(hasReduceFrequency ? 2 : 1);
+ const reduce = hasReduceFrequency ? buttons[0] : null;
+ const back = buttons[hasReduceFrequency ? 1 : 0];
if (reduce) {
await expect(reduce).toBeInTheDocument();
await expect(reduce).toHaveTextContent(i18n.ts._ad.reduceFrequencyOfThisAd);
@@ -80,15 +88,16 @@ const common = {
lock = undefined;
}
},
- */
args: {
prefer: [],
specify: {
id: 'someadid',
- radio: 1,
+ ratio: 1,
url: '#test',
+ place: '',
+ imageUrl: '',
+ dayOfWeek: 7,
},
- __hasReduce: true,
},
parameters: {
layout: 'centered',
@@ -138,6 +147,5 @@ export const ZeroRatio = {
...Square.args.specify,
ratio: 0,
},
- __hasReduce: false,
},
} satisfies StoryObj<typeof MkAd>;
diff --git a/packages/frontend/src/components/global/MkAd.vue b/packages/frontend/src/components/global/MkAd.vue
index 8f5ed760d5..bdaa8a809f 100644
--- a/packages/frontend/src/components/global/MkAd.vue
+++ b/packages/frontend/src/components/global/MkAd.vue
@@ -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 {
diff --git a/packages/frontend/src/components/global/MkAvatar.stories.impl.ts b/packages/frontend/src/components/global/MkAvatar.stories.impl.ts
index 933754ec4c..9d2de9f0be 100644
--- a/packages/frontend/src/components/global/MkAvatar.stories.impl.ts
+++ b/packages/frontend/src/components/global/MkAvatar.stories.impl.ts
@@ -33,7 +33,7 @@ const common = {
},
decorators: [
(Story, context) => ({
- // eslint-disable-next-line quotes
+ // @ts-expect-error size is for test
template: `<div :style="{ display: 'grid', width: '${context.args.size}px', height: '${context.args.size}px' }"><story/></div>`,
}),
],
@@ -45,6 +45,7 @@ export const ProfilePage = {
...common,
args: {
...common.args,
+ // @ts-expect-error size is for test
size: 120,
indicator: true,
},
diff --git a/packages/frontend/src/components/global/MkCondensedLine.stories.impl.ts b/packages/frontend/src/components/global/MkCondensedLine.stories.impl.ts
index e4e90cddd5..e15dcba760 100644
--- a/packages/frontend/src/components/global/MkCondensedLine.stories.impl.ts
+++ b/packages/frontend/src/components/global/MkCondensedLine.stories.impl.ts
@@ -28,6 +28,7 @@ export const Default = {
};
},
args: {
+ // @ts-expect-error text is for test
text: 'This is a condensed line.',
},
parameters: {
@@ -41,4 +42,5 @@ export const ContainerIs100px = {
template: '<div style="width: 100px;"><story/></div>',
}),
],
+ // @ts-expect-error text is for test
} satisfies StoryObj<typeof MkCondensedLine>;
diff --git a/packages/frontend/src/components/global/MkError.stories.meta.ts b/packages/frontend/src/components/global/MkError.stories.meta.ts
index 1abbc56f50..cd7fada189 100644
--- a/packages/frontend/src/components/global/MkError.stories.meta.ts
+++ b/packages/frontend/src/components/global/MkError.stories.meta.ts
@@ -3,8 +3,11 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
+import { Meta } from '@storybook/vue3';
+import MkError from './MkError.vue';
+
export const argTypes = {
- retry: {
+ onRetry: {
action: 'retry',
},
-};
+} satisfies Meta<typeof MkError>['argTypes'];
diff --git a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts
index 4ed76f6bc4..cab8d9c704 100644
--- a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts
+++ b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts
@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { VNode, h, SetupContext } from 'vue';
+import { VNode, h, SetupContext, provide } from 'vue';
import * as mfm from 'mfm-js';
import * as Misskey from 'misskey-js';
import MkUrl from '@/components/global/MkUrl.vue';
@@ -16,7 +16,7 @@ import MkCode from '@/components/MkCode.vue';
import MkCodeInline from '@/components/MkCodeInline.vue';
import MkGoogle from '@/components/MkGoogle.vue';
import MkSparkle from '@/components/MkSparkle.vue';
-import MkA from '@/components/global/MkA.vue';
+import MkA, { MkABehavior } from '@/components/global/MkA.vue';
import { host } from '@/config.js';
import { defaultStore } from '@/store.js';
import { nyaize as doNyaize } from '@/scripts/nyaize.js';
@@ -43,6 +43,7 @@ type MfmProps = {
parsedNodes?: mfm.MfmNode[] | null;
enableEmojiMenu?: boolean;
enableEmojiMenuReaction?: boolean;
+ linkNavigationBehavior?: MkABehavior;
};
type MfmEvents = {
@@ -51,6 +52,8 @@ type MfmEvents = {
// eslint-disable-next-line import/no-default-export
export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEvents>['emit'] }) {
+ provide('linkNavigationBehavior', props.linkNavigationBehavior);
+
const isNote = props.isNote ?? true;
const shouldNyaize = props.nyaize ? props.nyaize === 'respect' ? props.author?.isCat : false : false;
diff --git a/packages/frontend/src/components/global/MkPageHeader.stories.impl.ts b/packages/frontend/src/components/global/MkPageHeader.stories.impl.ts
index eb74e874dd..1d079edd2c 100644
--- a/packages/frontend/src/components/global/MkPageHeader.stories.impl.ts
+++ b/packages/frontend/src/components/global/MkPageHeader.stories.impl.ts
@@ -33,7 +33,6 @@ export const Empty = {
await waitFor(async () => await wait);
},
args: {
- static: true,
tabs: [],
},
parameters: {
@@ -71,8 +70,8 @@ export const IconOnly = {
...Icon.args,
tabs: [
{
- ...Icon.args.tabs[0],
- title: undefined,
+ key: Icon.args.tabs[0].key,
+ icon: Icon.args.tabs[0].icon,
iconOnly: true,
},
],
diff --git a/packages/frontend/src/components/global/MkPageHeader.tabs.vue b/packages/frontend/src/components/global/MkPageHeader.tabs.vue
index e93b09721a..fcc46cc345 100644
--- a/packages/frontend/src/components/global/MkPageHeader.tabs.vue
+++ b/packages/frontend/src/components/global/MkPageHeader.tabs.vue
@@ -38,7 +38,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts">
export type Tab = {
key: string;
- title: string;
onClick?: (ev: MouseEvent) => void;
} & (
| {
diff --git a/packages/frontend/src/components/global/MkTime.stories.impl.ts b/packages/frontend/src/components/global/MkTime.stories.impl.ts
index 355c839113..ffd4a849a2 100644
--- a/packages/frontend/src/components/global/MkTime.stories.impl.ts
+++ b/packages/frontend/src/components/global/MkTime.stories.impl.ts
@@ -60,7 +60,7 @@ export const RelativeFuture = {
export const AbsoluteFuture = {
...Empty,
async play({ canvasElement, args }) {
- await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time));
+ await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(typeof args.time === 'string' ? new Date(args.time) : args.time ?? undefined));
},
args: {
...Empty.args,
@@ -97,7 +97,7 @@ export const RelativeNow = {
export const AbsoluteNow = {
...Empty,
async play({ canvasElement, args }) {
- await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time));
+ await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(typeof args.time === 'string' ? new Date(args.time) : args.time ?? undefined));
},
args: {
...Empty.args,
@@ -136,7 +136,7 @@ export const RelativeOneHourAgo = {
export const AbsoluteOneHourAgo = {
...Empty,
async play({ canvasElement, args }) {
- await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time));
+ await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(typeof args.time === 'string' ? new Date(args.time) : args.time ?? undefined));
},
args: {
...Empty.args,
@@ -175,7 +175,7 @@ export const RelativeOneDayAgo = {
export const AbsoluteOneDayAgo = {
...Empty,
async play({ canvasElement, args }) {
- await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time));
+ await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(typeof args.time === 'string' ? new Date(args.time) : args.time ?? undefined));
},
args: {
...Empty.args,
@@ -214,7 +214,7 @@ export const RelativeOneWeekAgo = {
export const AbsoluteOneWeekAgo = {
...Empty,
async play({ canvasElement, args }) {
- await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time));
+ await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(typeof args.time === 'string' ? new Date(args.time) : args.time ?? undefined));
},
args: {
...Empty.args,
@@ -253,7 +253,7 @@ export const RelativeOneMonthAgo = {
export const AbsoluteOneMonthAgo = {
...Empty,
async play({ canvasElement, args }) {
- await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time));
+ await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(typeof args.time === 'string' ? new Date(args.time) : args.time ?? undefined));
},
args: {
...Empty.args,
@@ -292,7 +292,7 @@ export const RelativeOneYearAgo = {
export const AbsoluteOneYearAgo = {
...Empty,
async play({ canvasElement, args }) {
- await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time));
+ await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(typeof args.time === 'string' ? new Date(args.time) : args.time ?? undefined));
},
args: {
...Empty.args,
diff --git a/packages/frontend/src/components/global/MkTime.vue b/packages/frontend/src/components/global/MkTime.vue
index 67532268d3..23fe99bd9c 100644
--- a/packages/frontend/src/components/global/MkTime.vue
+++ b/packages/frontend/src/components/global/MkTime.vue
@@ -47,7 +47,7 @@ const invalid = Number.isNaN(_time);
const absolute = !invalid ? dateTimeFormat.format(_time) : i18n.ts._ago.invalid;
// eslint-disable-next-line vue/no-setup-props-destructure
-const now = ref((props.origin ?? new Date()).getTime());
+const now = ref(props.origin?.getTime() ?? Date.now());
const ago = computed(() => (now.value - _time) / 1000/*ms*/);
const relative = computed<string>(() => {
@@ -77,7 +77,7 @@ let tickId: number;
let currentInterval: number;
function tick() {
- now.value = (new Date()).getTime();
+ now.value = Date.now();
const nextInterval = ago.value < 60 ? 10000 : ago.value < 3600 ? 60000 : 180000;
if (currentInterval !== nextInterval) {
diff --git a/packages/frontend/src/components/global/MkUrl.vue b/packages/frontend/src/components/global/MkUrl.vue
index 0c3eee63ff..9d4cd559d9 100644
--- a/packages/frontend/src/components/global/MkUrl.vue
+++ b/packages/frontend/src/components/global/MkUrl.vue
@@ -6,6 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<component
:is="self ? 'MkA' : 'a'" ref="el" :class="$style.root" class="_link" :[attr]="self ? props.url.substring(local.length) : props.url" :rel="rel ?? 'nofollow noopener'" :target="target"
+ :behavior="props.navigationBehavior"
@contextmenu.stop="() => {}"
>
<template v-if="!self">
@@ -30,11 +31,14 @@ import { url as local } from '@/config.js';
import * as os from '@/os.js';
import { useTooltip } from '@/scripts/use-tooltip.js';
import { safeURIDecode } from '@/scripts/safe-uri-decode.js';
+import { isEnabledUrlPreview } from '@/instance.js';
+import { MkABehavior } from '@/components/global/MkA.vue';
const props = withDefaults(defineProps<{
url: string;
rel?: string;
showUrlPreview?: boolean;
+ navigationBehavior?: MkABehavior;
}>(), {
showUrlPreview: true,
});
@@ -44,12 +48,12 @@ const url = new URL(props.url);
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('invalid url');
const el = ref();
-if (props.showUrlPreview) {
+if (props.showUrlPreview && isEnabledUrlPreview.value) {
useTooltip(el, (showing) => {
os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), {
showing,
url: props.url,
- source: el.value,
+ source: el.value instanceof HTMLElement ? el.value : el.value?.$el,
}, {}, 'closed');
});
}
diff --git a/packages/frontend/src/components/global/MkUserName.stories.impl.ts b/packages/frontend/src/components/global/MkUserName.stories.impl.ts
index 88bf4f4e6c..e39061c291 100644
--- a/packages/frontend/src/components/global/MkUserName.stories.impl.ts
+++ b/packages/frontend/src/components/global/MkUserName.stories.impl.ts
@@ -30,7 +30,7 @@ export const Default = {
};
},
async play({ canvasElement }) {
- await expect(canvasElement).toHaveTextContent(userDetailed().name);
+ await expect(canvasElement).toHaveTextContent(userDetailed().name as string);
},
args: {
user: userDetailed(),
diff --git a/packages/frontend/src/components/page/page.block.vue b/packages/frontend/src/components/page/page.block.vue
index 164720ac6b..c7f72dce8c 100644
--- a/packages/frontend/src/components/page/page.block.vue
+++ b/packages/frontend/src/components/page/page.block.vue
@@ -14,6 +14,7 @@ import XText from './page.text.vue';
import XSection from './page.section.vue';
import XImage from './page.image.vue';
import XNote from './page.note.vue';
+import XDynamic from './page.dynamic.vue';
function getComponent(type: string) {
switch (type) {
@@ -21,6 +22,20 @@ function getComponent(type: string) {
case 'section': return XSection;
case 'image': return XImage;
case 'note': return XNote;
+
+ // 動的ページの代替用ブロック
+ case 'button':
+ case 'if':
+ case 'textarea':
+ case 'post':
+ case 'canvas':
+ case 'numberInput':
+ case 'textInput':
+ case 'switch':
+ case 'radioButton':
+ case 'counter':
+ return XDynamic;
+
default: return null;
}
}
diff --git a/packages/frontend/src/components/page/page.dynamic.vue b/packages/frontend/src/components/page/page.dynamic.vue
new file mode 100644
index 0000000000..8c511a690d
--- /dev/null
+++ b/packages/frontend/src/components/page/page.dynamic.vue
@@ -0,0 +1,43 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<!-- 動的ページのブロックの代替。利用できないということを表示する -->
+<template>
+<div :class="$style.root">
+ <div :class="$style.heading"><i class="ti ti-dice-5"></i> {{ i18n.ts._pages.blocks.dynamic }}</div>
+ <I18n :src="i18n.ts._pages.blocks.dynamicDescription" tag="div" :class="$style.text">
+ <template #play>
+ <MkA to="/play" class="_link">Play</MkA>
+ </template>
+ </I18n>
+</div>
+</template>
+
+<script lang="ts" setup>
+import * as Misskey from 'misskey-js';
+import { i18n } from '@/i18n.js';
+
+const props = defineProps<{
+ block: Misskey.entities.PageBlock,
+ page: Misskey.entities.Page,
+}>();
+</script>
+
+<style lang="scss" module>
+.root {
+ border: 1px solid var(--divider);
+ border-radius: var(--radius);
+ padding: var(--margin);
+ text-align: center;
+}
+
+.heading {
+ font-weight: 700;
+}
+
+.text {
+ font-size: 90%;
+}
+</style>
diff --git a/packages/frontend/src/components/page/page.image.vue b/packages/frontend/src/components/page/page.image.vue
index ced02943db..fc1ce9fc7b 100644
--- a/packages/frontend/src/components/page/page.image.vue
+++ b/packages/frontend/src/components/page/page.image.vue
@@ -4,19 +4,15 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
-<div>
- <MediaImage
- v-if="image"
- :image="image"
- :disableImageLink="true"
- />
+<div :class="$style.root">
+ <MkMediaList v-if="image" :mediaList="[image]" :class="$style.mediaList"/>
</div>
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import * as Misskey from 'misskey-js';
-import MediaImage from '@/components/MkMediaImage.vue';
+import MkMediaList from '@/components/MkMediaList.vue';
const props = defineProps<{
block: Misskey.entities.PageBlock,
@@ -28,5 +24,17 @@ const image = ref<Misskey.entities.DriveFile | null>(null);
onMounted(() => {
image.value = props.page.attachedFiles.find(x => x.id === props.block.fileId) ?? null;
});
-
</script>
+
+<style lang="scss" module>
+.root {
+ border: 1px solid var(--divider);
+ border-radius: var(--radius);
+ overflow: hidden;
+}
+.mediaList {
+ // MkMediaList 内の上部マージン 4px
+ margin-top: -4px;
+ height: calc(100% + 4px);
+}
+</style>
diff --git a/packages/frontend/src/components/page/page.note.vue b/packages/frontend/src/components/page/page.note.vue
index 7b56494a6e..b5ba407806 100644
--- a/packages/frontend/src/components/page/page.note.vue
+++ b/packages/frontend/src/components/page/page.note.vue
@@ -4,9 +4,9 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
-<div style="margin: 1em 0;">
- <MkNote v-if="note && !block.detailed" :key="note.id + ':normal'" v-model:note="note"/>
- <MkNoteDetailed v-if="note && block.detailed" :key="note.id + ':detail'" v-model:note="note"/>
+<div :class="$style.root">
+ <MkNote v-if="note && !block.detailed" :key="note.id + ':normal'" :note="note"/>
+ <MkNoteDetailed v-if="note && block.detailed" :key="note.id + ':detail'" :note="note"/>
</div>
</template>
@@ -32,3 +32,10 @@ onMounted(() => {
});
});
</script>
+
+<style lang="scss" module>
+.root {
+ border: 1px solid var(--divider);
+ border-radius: var(--radius);
+}
+</style>
diff --git a/packages/frontend/src/components/page/page.text.vue b/packages/frontend/src/components/page/page.text.vue
index 81a4c4fa93..e0c7956f6e 100644
--- a/packages/frontend/src/components/page/page.text.vue
+++ b/packages/frontend/src/components/page/page.text.vue
@@ -4,9 +4,11 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
-<div class="_gaps">
+<div class="_gaps" :class="$style.textRoot">
<Mfm :text="block.text ?? ''" :isNote="false"/>
- <MkUrlPreview v-for="url in urls" :key="url" :url="url"/>
+ <div v-if="isEnabledUrlPreview" class="_gaps_s">
+ <MkUrlPreview v-for="url in urls" :key="url" :url="url"/>
+ </div>
</div>
</template>
@@ -15,6 +17,7 @@ import { defineAsyncComponent } from 'vue';
import * as mfm from 'mfm-js';
import * as Misskey from 'misskey-js';
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js';
+import { isEnabledUrlPreview } from '@/instance.js';
const MkUrlPreview = defineAsyncComponent(() => import('@/components/MkUrlPreview.vue'));
@@ -25,3 +28,9 @@ const props = defineProps<{
const urls = props.block.text ? extractUrlFromMfm(mfm.parse(props.block.text)) : [];
</script>
+
+<style lang="scss" module>
+.textRoot {
+ font-size: 1.1rem;
+}
+</style>
diff --git a/packages/frontend/src/components/page/page.vue b/packages/frontend/src/components/page/page.vue
index 53c70b01f4..a31c5eff28 100644
--- a/packages/frontend/src/components/page/page.vue
+++ b/packages/frontend/src/components/page/page.vue
@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
-<div :class="{ [$style.center]: page.alignCenter, [$style.serif]: page.font === 'serif' }" class="_gaps_s">
+<div :class="{ [$style.center]: page.alignCenter, [$style.serif]: page.font === 'serif' }" class="_gaps">
<XBlock v-for="child in page.content" :key="child.id" :page="page" :block="child" :h="2"/>
</div>
</template>
diff --git a/packages/frontend/src/filters/kmg.ts b/packages/frontend/src/filters/kmg.ts
index 4dcb5c5800..9608e420f6 100644
--- a/packages/frontend/src/filters/kmg.ts
+++ b/packages/frontend/src/filters/kmg.ts
@@ -1,3 +1,8 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
export default (v, fractionDigits = 0) => {
if (v == null) return 'N/A';
if (v === 0) return '0';
diff --git a/packages/frontend/src/index.html b/packages/frontend/src/index.html
index cd84145f40..08ff0c58dd 100644
--- a/packages/frontend/src/index.html
+++ b/packages/frontend/src/index.html
@@ -18,7 +18,7 @@
http-equiv="Content-Security-Policy"
content="default-src 'self' https://newassets.hcaptcha.com/ https://challenges.cloudflare.com/ http://localhost:7493/;
worker-src 'self';
- script-src 'self' 'unsafe-eval' https://*.hcaptcha.com https://challenges.cloudflare.com;
+ script-src 'self' 'unsafe-eval' https://*.hcaptcha.com https://challenges.cloudflare.com https://esm.sh;
style-src 'self' 'unsafe-inline';
img-src 'self' data: blob: www.google.com xn--931a.moe localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000;
media-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000;
diff --git a/packages/frontend/src/instance.ts b/packages/frontend/src/instance.ts
index 4232cbcd78..6847321d6c 100644
--- a/packages/frontend/src/instance.ts
+++ b/packages/frontend/src/instance.ts
@@ -28,7 +28,7 @@ if (providedAt > cachedAt) {
// TODO: instanceをリアクティブにするかは再考の余地あり
-export const instance: Misskey.entities.MetaResponse = reactive(cachedMeta ?? {});
+export const instance: Misskey.entities.MetaDetailed = reactive(cachedMeta ?? {});
export const serverErrorImageUrl = computed(() => instance.serverErrorImageUrl ?? DEFAULT_SERVER_ERROR_IMAGE_URL);
@@ -36,17 +36,19 @@ export const infoImageUrl = computed(() => instance.infoImageUrl ?? DEFAULT_INFO
export const notFoundImageUrl = computed(() => instance.notFoundImageUrl ?? DEFAULT_NOT_FOUND_IMAGE_URL);
-export async function fetchInstance(force = false): Promise<void> {
+export const isEnabledUrlPreview = computed(() => instance.enableUrlPreview ?? true);
+
+export async function fetchInstance(force = false): Promise<Misskey.entities.MetaDetailed> {
if (!force) {
const cachedAt = miLocalStorage.getItem('instanceCachedAt') ? parseInt(miLocalStorage.getItem('instanceCachedAt')!) : 0;
if (Date.now() - cachedAt < 1000 * 60 * 60) {
- return;
+ return instance;
}
}
const meta = await misskeyApi('meta', {
- detail: false,
+ detail: true,
});
for (const [k, v] of Object.entries(meta)) {
@@ -55,4 +57,6 @@ export async function fetchInstance(force = false): Promise<void> {
miLocalStorage.setItem('instance', JSON.stringify(instance));
miLocalStorage.setItem('instanceCachedAt', Date.now().toString());
+
+ return instance;
}
diff --git a/packages/frontend/src/nirax.ts b/packages/frontend/src/nirax.ts
index 616fb104e6..6a8ea09ed6 100644
--- a/packages/frontend/src/nirax.ts
+++ b/packages/frontend/src/nirax.ts
@@ -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) {
diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts
index c561e84a23..f656a52371 100644
--- a/packages/frontend/src/os.ts
+++ b/packages/frontend/src/os.ts
@@ -518,7 +518,7 @@ export function waiting(): Promise<void> {
});
}
-export function form<F extends Form>(title: string, f: F): Promise<{ canceled: true } | { result: GetFormResultType<F> }> {
+export function form<F extends Form>(title: string, f: F): Promise<{ canceled: true, result?: undefined } | { canceled?: false, result: GetFormResultType<F> }> {
return new Promise(resolve => {
popup(defineAsyncComponent(() => import('@/components/MkFormDialog.vue')), { title, form: f }, {
done: result => {
diff --git a/packages/frontend/src/pages/about-misskey.vue b/packages/frontend/src/pages/about-misskey.vue
index 1a49dbf1d5..b55ae220d8 100644
--- a/packages/frontend/src/pages/about-misskey.vue
+++ b/packages/frontend/src/pages/about-misskey.vue
@@ -222,6 +222,24 @@ const patronsWithIcon = [{
}, {
name: '有栖かずみ',
icon: 'https://assets.misskey-hub.net/patrons/9240e8e0ba294a8884143e99ac7ed6a0.jpg',
+}, {
+ name: 'イカロ(コアラ)',
+ icon: 'https://assets.misskey-hub.net/patrons/50b9bdc03735412c80807dbdf32cecb6.jpg',
+}, {
+ name: 'ハチノス3号',
+ icon: 'https://assets.misskey-hub.net/patrons/030347a6f8ce4e82bc5184b5aad09a18.jpg',
+}, {
+ name: 'Takeno',
+ icon: 'https://assets.misskey-hub.net/patrons/6fba81536aea48fe94a30909c502dfa1.jpg',
+}, {
+ name: 'くびすじ',
+ icon: 'https://assets.misskey-hub.net/patrons/aa5789850b2149aeb5b89ebe2e9083db.jpg',
+}, {
+ name: '古道京紗@ぷらいべったー',
+ icon: 'https://assets.misskey-hub.net/patrons/18346d0519704963a4beabe6abc170af.jpg',
+}, {
+ name: '越貝鯛丸',
+ icon: 'https://assets.misskey-hub.net/patrons/86c7374de37849b882d8ebbc833dc968.jpg',
}];
const patrons = [
@@ -324,6 +342,8 @@ const patrons = [
'てば',
'たっくん',
'SHO SEKIGUCHI',
+ '塩キャベツ',
+ 'はとぽぷさん',
];
const thereIsTreasure = ref($i && !claimedAchievements.includes('foundTreasure'));
diff --git a/packages/frontend/src/pages/admin-user.vue b/packages/frontend/src/pages/admin-user.vue
index 2cef55df6c..f57aa51b5b 100644
--- a/packages/frontend/src/pages/admin-user.vue
+++ b/packages/frontend/src/pages/admin-user.vue
@@ -416,7 +416,7 @@ async function assignRole() {
if (canceled) return;
const { canceled: canceled2, result: period } = await os.select({
- title: i18n.ts.period,
+ title: i18n.ts.period + ': ' + roles.find(r => r.id === roleId)!.name,
items: [{
value: 'indefinitely', text: i18n.ts.indefinitely,
}, {
diff --git a/packages/frontend/src/pages/admin/RolesEditorFormula.vue b/packages/frontend/src/pages/admin/RolesEditorFormula.vue
index 2f5b4c47d8..f001a4ac20 100644
--- a/packages/frontend/src/pages/admin/RolesEditorFormula.vue
+++ b/packages/frontend/src/pages/admin/RolesEditorFormula.vue
@@ -9,6 +9,11 @@ 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="isSuspended">{{ i18n.ts._role._condition.isSuspended }}</option>
+ <option value="isLocked">{{ i18n.ts._role._condition.isLocked }}</option>
+ <option value="isBot">{{ i18n.ts._role._condition.isBot }}</option>
+ <option value="isCat">{{ i18n.ts._role._condition.isCat }}</option>
+ <option value="isExplorable">{{ i18n.ts._role._condition.isExplorable }}</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>
diff --git a/packages/frontend/src/pages/admin/federation.vue b/packages/frontend/src/pages/admin/federation.vue
index de27e1f67a..0aaa398584 100644
--- a/packages/frontend/src/pages/admin/federation.vue
+++ b/packages/frontend/src/pages/admin/federation.vue
@@ -58,6 +58,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
+import * as Misskey from 'misskey-js';
import { computed, ref } from 'vue';
import XHeader from './_header_.vue';
import MkInput from '@/components/MkInput.vue';
@@ -90,8 +91,17 @@ const pagination = {
})),
};
-function getStatus(instance) {
- if (instance.isSuspended) return 'Suspended';
+function getStatus(instance: Misskey.entities.FederationInstance) {
+ switch (instance.suspensionState) {
+ case 'manuallySuspended':
+ return 'Manually Suspended';
+ case 'goneSuspended':
+ return 'Automatically Suspended (Gone)';
+ case 'autoSuspendedForNotResponding':
+ return 'Automatically Suspended (Not Responding)';
+ case 'none':
+ break;
+ }
if (instance.isBlocked) return 'Blocked';
if (instance.isSilenced) return 'Silenced';
if (instance.isNotResponding) return 'Error';
diff --git a/packages/frontend/src/pages/admin/files.vue b/packages/frontend/src/pages/admin/files.vue
index 3fe021e771..5132b85c64 100644
--- a/packages/frontend/src/pages/admin/files.vue
+++ b/packages/frontend/src/pages/admin/files.vue
@@ -42,7 +42,7 @@ import MkInput from '@/components/MkInput.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkFileListForAdmin from '@/components/MkFileListForAdmin.vue';
import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { lookupFile } from '@/scripts/admin-lookup.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
@@ -73,33 +73,10 @@ function clear() {
});
}
-function show(file) {
- os.pageWindow(`/admin/file/${file.id}`);
-}
-
-async function find() {
- const { canceled, result: q } = await os.inputText({
- title: i18n.ts.fileIdOrUrl,
- minLength: 1,
- });
- if (canceled) return;
-
- misskeyApi('admin/drive/show-file', q.startsWith('http://') || q.startsWith('https://') ? { url: q.trim() } : { fileId: q.trim() }).then(file => {
- show(file);
- }).catch(err => {
- if (err.code === 'NO_SUCH_FILE') {
- os.alert({
- type: 'error',
- text: i18n.ts.notFound,
- });
- }
- });
-}
-
const headerActions = computed(() => [{
text: i18n.ts.lookup,
icon: 'ti ti-search',
- handler: find,
+ handler: lookupFile,
}, {
text: i18n.ts.clearCachedFiles,
icon: 'ti ti-trash',
diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue
index d4a41c66cc..794feae202 100644
--- a/packages/frontend/src/pages/admin/index.vue
+++ b/packages/frontend/src/pages/admin/index.vue
@@ -12,10 +12,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<img :src="instance.iconUrl || '/favicon.ico'" alt="" class="icon"/>
</div>
- <MkInfo v-if="thereIsUnresolvedAbuseReport" warn class="info">{{ i18n.ts.thereIsUnresolvedAbuseReportWarning }} <MkA to="/admin/abuses" class="_link">{{ i18n.ts.check }}</MkA></MkInfo>
- <MkInfo v-if="noMaintainerInformation" warn class="info">{{ i18n.ts.noMaintainerInformationWarning }} <MkA to="/admin/settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo>
- <MkInfo v-if="noBotProtection" warn class="info">{{ i18n.ts.noBotProtectionWarning }} <MkA to="/admin/security" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo>
- <MkInfo v-if="noEmailServer" warn class="info">{{ i18n.ts.noEmailServerWarning }} <MkA to="/admin/email-settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo>
+ <div class="_gaps_s">
+ <MkInfo v-if="thereIsUnresolvedAbuseReport" warn>{{ i18n.ts.thereIsUnresolvedAbuseReportWarning }} <MkA to="/admin/abuses" class="_link">{{ i18n.ts.check }}</MkA></MkInfo>
+ <MkInfo v-if="noMaintainerInformation" warn>{{ i18n.ts.noMaintainerInformationWarning }} <MkA to="/admin/settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo>
+ <MkInfo v-if="noInquiryUrl" warn>{{ i18n.ts.noInquiryUrlWarning }} <MkA to="/admin/moderation" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo>
+ <MkInfo v-if="noBotProtection" warn>{{ i18n.ts.noBotProtectionWarning }} <MkA to="/admin/security" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo>
+ <MkInfo v-if="noEmailServer" warn>{{ i18n.ts.noEmailServerWarning }} <MkA to="/admin/email-settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo>
+ </div>
<MkSuperMenu :def="menuDef" :grid="narrow"></MkSuperMenu>
</div>
@@ -33,9 +36,10 @@ import { i18n } from '@/i18n.js';
import MkSuperMenu from '@/components/MkSuperMenu.vue';
import MkInfo from '@/components/MkInfo.vue';
import { instance } from '@/instance.js';
+import { lookup } from '@/scripts/lookup.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
-import { lookupUser, lookupUserByEmail } from '@/scripts/lookup-user.js';
+import { lookupUser, lookupUserByEmail, lookupFile } from '@/scripts/admin-lookup.js';
import { PageMetadata, definePageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js';
import { useRouter } from '@/router/supplier.js';
@@ -60,6 +64,7 @@ const pageProps = ref({});
let noMaintainerInformation = isEmpty(instance.maintainerName) || isEmpty(instance.maintainerEmail);
let noBotProtection = !instance.disableRegistration && !instance.enableHcaptcha && !instance.enableRecaptcha && !instance.enableTurnstile;
let noEmailServer = !instance.enableEmail;
+let noInquiryUrl = isEmpty(instance.inquiryUrl);
const thereIsUnresolvedAbuseReport = ref(false);
const currentPage = computed(() => router.currentRef.value.child);
@@ -82,7 +87,7 @@ const menuDef = computed(() => [{
type: 'button',
icon: 'ti ti-search',
text: i18n.ts.lookup,
- action: lookup,
+ action: adminLookup,
}, ...(instance.disableRegistration ? [{
type: 'button',
icon: 'ti ti-user-plus',
@@ -282,7 +287,7 @@ function invite() {
});
}
-function lookup(ev: MouseEvent) {
+function adminLookup(ev: MouseEvent) {
os.popupMenu([{
text: i18n.ts.user,
icon: 'ti ti-user',
@@ -296,22 +301,16 @@ function lookup(ev: MouseEvent) {
lookupUserByEmail();
},
}, {
- text: i18n.ts.note,
- icon: 'ti ti-pencil',
- action: () => {
- alert('TODO');
- },
- }, {
text: i18n.ts.file,
icon: 'ti ti-cloud',
action: () => {
- alert('TODO');
+ lookupFile();
},
}, {
- text: i18n.ts.instance,
- icon: 'ti ti-planet',
+ text: i18n.ts.lookup,
+ icon: 'ti ti-world-search',
action: () => {
- alert('TODO');
+ lookup();
},
}], ev.currentTarget ?? ev.target);
}
@@ -353,10 +352,6 @@ defineExpose({
> .nav {
.lxpfedzu {
- > .info {
- margin: 16px 0;
- }
-
> .banner {
margin: 16px;
diff --git a/packages/frontend/src/pages/admin/moderation.vue b/packages/frontend/src/pages/admin/moderation.vue
index 9efb34ac9a..a75799696d 100644
--- a/packages/frontend/src/pages/admin/moderation.vue
+++ b/packages/frontend/src/pages/admin/moderation.vue
@@ -30,6 +30,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts.privacyPolicyUrl }}</template>
</MkInput>
+ <MkInput v-model="inquiryUrl" type="url">
+ <template #prefix><i class="ti ti-link"></i></template>
+ <template #label>{{ i18n.ts._serverSettings.inquiryUrl }}</template>
+ <template #caption>{{ i18n.ts._serverSettings.inquiryUrlDescription }}</template>
+ </MkInput>
+
<MkTextarea v-model="preservedUsernames">
<template #label>{{ i18n.ts.preservedUsernames }}</template>
<template #caption>{{ i18n.ts.preservedUsernamesDescription }}</template>
@@ -86,6 +92,7 @@ const hiddenTags = ref<string>('');
const preservedUsernames = ref<string>('');
const tosUrl = ref<string | null>(null);
const privacyPolicyUrl = ref<string | null>(null);
+const inquiryUrl = ref<string | null>(null);
async function init() {
const meta = await misskeyApi('admin/meta');
@@ -97,6 +104,7 @@ async function init() {
preservedUsernames.value = meta.preservedUsernames.join('\n');
tosUrl.value = meta.tosUrl;
privacyPolicyUrl.value = meta.privacyPolicyUrl;
+ inquiryUrl.value = meta.inquiryUrl;
}
function save() {
@@ -105,6 +113,7 @@ function save() {
emailRequiredForSignup: emailRequiredForSignup.value,
tosUrl: tosUrl.value,
privacyPolicyUrl: privacyPolicyUrl.value,
+ inquiryUrl: inquiryUrl.value,
sensitiveWords: sensitiveWords.value.split('\n'),
prohibitedWords: prohibitedWords.value.split('\n'),
hiddenTags: hiddenTags.value.split('\n'),
diff --git a/packages/frontend/src/pages/admin/roles.role.vue b/packages/frontend/src/pages/admin/roles.role.vue
index ab8005045b..8b3c906d8a 100644
--- a/packages/frontend/src/pages/admin/roles.role.vue
+++ b/packages/frontend/src/pages/admin/roles.role.vue
@@ -119,7 +119,7 @@ async function assign() {
const user = await os.selectUser({ includeSelf: true });
const { canceled: canceled2, result: period } = await os.select({
- title: i18n.ts.period,
+ title: i18n.ts.period + ': ' + role.name,
items: [{
value: 'indefinitely', text: i18n.ts.indefinitely,
}, {
diff --git a/packages/frontend/src/pages/admin/security.vue b/packages/frontend/src/pages/admin/security.vue
index c4745978df..9bccee89a5 100644
--- a/packages/frontend/src/pages/admin/security.vue
+++ b/packages/frontend/src/pages/admin/security.vue
@@ -118,19 +118,6 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch>
</div>
</MkFolder>
-
- <MkFolder>
- <template #label>Summaly Proxy</template>
-
- <div class="_gaps_m">
- <MkInput v-model="summalyProxy">
- <template #prefix><i class="ti ti-link"></i></template>
- <template #label>Summaly Proxy URL</template>
- </MkInput>
-
- <MkButton primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
- </div>
- </MkFolder>
</div>
</FormSuspense>
</MkSpacer>
@@ -155,7 +142,6 @@ import { fetchInstance } from '@/instance.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
-const summalyProxy = ref<string>('');
const enableHcaptcha = ref<boolean>(false);
const enableMcaptcha = ref<boolean>(false);
const enableRecaptcha = ref<boolean>(false);
@@ -175,7 +161,6 @@ const bannedEmailDomains = ref<string>('');
async function init() {
const meta = await misskeyApi('admin/meta');
- summalyProxy.value = meta.summalyProxy;
enableHcaptcha.value = meta.enableHcaptcha;
enableMcaptcha.value = meta.enableMcaptcha;
enableRecaptcha.value = meta.enableRecaptcha;
@@ -201,7 +186,6 @@ async function init() {
function save() {
os.apiWithDialog('admin/update-meta', {
- summalyProxy: summalyProxy.value,
sensitiveMediaDetection: sensitiveMediaDetection.value,
sensitiveMediaDetectionSensitivity:
sensitiveMediaDetectionSensitivity.value === 0 ? 'veryLow' :
diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue
index 9a198ee8a3..6f45c212ec 100644
--- a/packages/frontend/src/pages/admin/settings.vue
+++ b/packages/frontend/src/pages/admin/settings.vue
@@ -143,6 +143,53 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
</FormSection>
+
+ <FormSection>
+ <template #label>{{ i18n.ts._urlPreviewSetting.title }}</template>
+
+ <div class="_gaps_m">
+ <MkSwitch v-model="urlPreviewEnabled">
+ <template #label>{{ i18n.ts._urlPreviewSetting.enable }}</template>
+ </MkSwitch>
+
+ <MkSwitch v-model="urlPreviewRequireContentLength">
+ <template #label>{{ i18n.ts._urlPreviewSetting.requireContentLength }}</template>
+ <template #caption>{{ i18n.ts._urlPreviewSetting.requireContentLengthDescription }}</template>
+ </MkSwitch>
+
+ <MkInput v-model="urlPreviewMaximumContentLength" type="number">
+ <template #label>{{ i18n.ts._urlPreviewSetting.maximumContentLength }}</template>
+ <template #caption>{{ i18n.ts._urlPreviewSetting.maximumContentLengthDescription }}</template>
+ </MkInput>
+
+ <MkInput v-model="urlPreviewTimeout" type="number">
+ <template #label>{{ i18n.ts._urlPreviewSetting.timeout }}</template>
+ <template #caption>{{ i18n.ts._urlPreviewSetting.timeoutDescription }}</template>
+ </MkInput>
+
+ <MkInput v-model="urlPreviewUserAgent" type="text">
+ <template #label>{{ i18n.ts._urlPreviewSetting.userAgent }}</template>
+ <template #caption>{{ i18n.ts._urlPreviewSetting.userAgentDescription }}</template>
+ </MkInput>
+
+ <div>
+ <MkInput v-model="urlPreviewSummaryProxyUrl" type="text">
+ <template #label>{{ i18n.ts._urlPreviewSetting.summaryProxy }}</template>
+ <template #caption>[{{ i18n.ts.notUsePleaseLeaveBlank }}] {{ i18n.ts._urlPreviewSetting.summaryProxyDescription }}</template>
+ </MkInput>
+
+ <div :class="$style.subCaption">
+ {{ i18n.ts._urlPreviewSetting.summaryProxyDescription2 }}
+ <ul style="padding-left: 20px; margin: 4px 0">
+ <li>{{ i18n.ts._urlPreviewSetting.timeout }} / key:timeout</li>
+ <li>{{ i18n.ts._urlPreviewSetting.maximumContentLength }} / key:contentLengthLimit</li>
+ <li>{{ i18n.ts._urlPreviewSetting.requireContentLength }} / key:contentLengthRequired</li>
+ <li>{{ i18n.ts._urlPreviewSetting.userAgent }} / key:userAgent</li>
+ </ul>
+ </div>
+ </div>
+ </div>
+ </FormSection>
</div>
</FormSuspense>
</MkSpacer>
@@ -173,6 +220,8 @@ import { fetchInstance, instance } from '@/instance.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import MkButton from '@/components/MkButton.vue';
+import MkFolder from '@/components/MkFolder.vue';
+import MkSelect from '@/components/MkSelect.vue';
const name = ref<string | null>(null);
const shortName = ref<string | null>(null);
@@ -194,6 +243,12 @@ const perRemoteUserUserTimelineCacheMax = ref<number>(0);
const perUserHomeTimelineCacheMax = ref<number>(0);
const perUserListTimelineCacheMax = ref<number>(0);
const notesPerOneAd = ref<number>(0);
+const urlPreviewEnabled = ref<boolean>(true);
+const urlPreviewTimeout = ref<number>(10000);
+const urlPreviewMaximumContentLength = ref<number>(1024 * 1024 * 10);
+const urlPreviewRequireContentLength = ref<boolean>(true);
+const urlPreviewUserAgent = ref<string | null>(null);
+const urlPreviewSummaryProxyUrl = ref<string | null>(null);
async function init(): Promise<void> {
const meta = await misskeyApi('admin/meta');
@@ -217,9 +272,15 @@ async function init(): Promise<void> {
perUserHomeTimelineCacheMax.value = meta.perUserHomeTimelineCacheMax;
perUserListTimelineCacheMax.value = meta.perUserListTimelineCacheMax;
notesPerOneAd.value = meta.notesPerOneAd;
+ urlPreviewEnabled.value = meta.urlPreviewEnabled;
+ urlPreviewTimeout.value = meta.urlPreviewTimeout;
+ urlPreviewMaximumContentLength.value = meta.urlPreviewMaximumContentLength;
+ urlPreviewRequireContentLength.value = meta.urlPreviewRequireContentLength;
+ urlPreviewUserAgent.value = meta.urlPreviewUserAgent;
+ urlPreviewSummaryProxyUrl.value = meta.urlPreviewSummaryProxyUrl;
}
-async function save(): void {
+async function save() {
await os.apiWithDialog('admin/update-meta', {
name: name.value,
shortName: shortName.value === '' ? null : shortName.value,
@@ -241,6 +302,12 @@ async function save(): void {
perUserHomeTimelineCacheMax: perUserHomeTimelineCacheMax.value,
perUserListTimelineCacheMax: perUserListTimelineCacheMax.value,
notesPerOneAd: notesPerOneAd.value,
+ urlPreviewEnabled: urlPreviewEnabled.value,
+ urlPreviewTimeout: urlPreviewTimeout.value,
+ urlPreviewMaximumContentLength: urlPreviewMaximumContentLength.value,
+ urlPreviewRequireContentLength: urlPreviewRequireContentLength.value,
+ urlPreviewUserAgent: urlPreviewUserAgent.value,
+ urlPreviewSummaryProxyUrl: urlPreviewSummaryProxyUrl.value,
});
fetchInstance(true);
@@ -259,4 +326,9 @@ definePageMetadata(() => ({
-webkit-backdrop-filter: var(--blur, blur(15px));
backdrop-filter: var(--blur, blur(15px));
}
+
+.subCaption {
+ font-size: 0.85em;
+ color: var(--fgTransparentWeak);
+}
</style>
diff --git a/packages/frontend/src/pages/admin/users.vue b/packages/frontend/src/pages/admin/users.vue
index 06317760d2..7d87b97a36 100644
--- a/packages/frontend/src/pages/admin/users.vue
+++ b/packages/frontend/src/pages/admin/users.vue
@@ -63,7 +63,7 @@ import MkInput from '@/components/MkInput.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkPagination from '@/components/MkPagination.vue';
import * as os from '@/os.js';
-import { lookupUser } from '@/scripts/lookup-user.js';
+import { lookupUser } from '@/scripts/admin-lookup.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import MkUserCardMini from '@/components/MkUserCardMini.vue';
diff --git a/packages/frontend/src/pages/announcement.vue b/packages/frontend/src/pages/announcement.vue
new file mode 100644
index 0000000000..85ae9062d4
--- /dev/null
+++ b/packages/frontend/src/pages/announcement.vue
@@ -0,0 +1,142 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkStickyContainer>
+ <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :contentMax="800">
+ <Transition
+ :enterActiveClass="defaultStore.state.animation ? $style.fadeEnterActive : ''"
+ :leaveActiveClass="defaultStore.state.animation ? $style.fadeLeaveActive : ''"
+ :enterFromClass="defaultStore.state.animation ? $style.fadeEnterFrom : ''"
+ :leaveToClass="defaultStore.state.animation ? $style.fadeLeaveTo : ''"
+ mode="out-in"
+ >
+ <div v-if="announcement" :key="announcement.id" class="_panel" :class="$style.announcement">
+ <div v-if="announcement.forYou" :class="$style.forYou"><i class="ti ti-pin"></i> {{ i18n.ts.forYou }}</div>
+ <div :class="$style.header">
+ <span v-if="$i && !announcement.silence && !announcement.isRead" style="margin-right: 0.5em;">🆕</span>
+ <span style="margin-right: 0.5em;">
+ <i v-if="announcement.icon === 'info'" class="ti ti-info-circle"></i>
+ <i v-else-if="announcement.icon === 'warning'" class="ti ti-alert-triangle" style="color: var(--warn);"></i>
+ <i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--error);"></i>
+ <i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--success);"></i>
+ </span>
+ <Mfm :text="announcement.title"/>
+ </div>
+ <div :class="$style.content">
+ <Mfm :text="announcement.text"/>
+ <img v-if="announcement.imageUrl" :src="announcement.imageUrl"/>
+ <div style="margin-top: 8px; opacity: 0.7; font-size: 85%;">
+ {{ i18n.ts.createdAt }}: <MkTime :time="announcement.createdAt" mode="detail"/>
+ </div>
+ <div v-if="announcement.updatedAt" style="opacity: 0.7; font-size: 85%;">
+ {{ i18n.ts.updatedAt }}: <MkTime :time="announcement.updatedAt" mode="detail"/>
+ </div>
+ </div>
+ <div v-if="$i && !announcement.silence && !announcement.isRead" :class="$style.footer">
+ <MkButton primary @click="read(announcement)"><i class="ti ti-check"></i> {{ i18n.ts.gotIt }}</MkButton>
+ </div>
+ </div>
+ <MkError v-else-if="error" @retry="fetch()"/>
+ <MkLoading v-else/>
+ </Transition>
+ </MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { ref, computed, watch } from 'vue';
+import * as Misskey from 'misskey-js';
+import MkButton from '@/components/MkButton.vue';
+import * as os from '@/os.js';
+import { misskeyApi } from '@/scripts/misskey-api.js';
+import { i18n } from '@/i18n.js';
+import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { $i, updateAccount } from '@/account.js';
+import { defaultStore } from '@/store.js';
+
+const props = defineProps<{
+ announcementId: string;
+}>();
+
+const announcement = ref<Misskey.entities.Announcement | null>(null);
+const error = ref<any>(null);
+const path = computed(() => props.announcementId);
+
+function fetch() {
+ announcement.value = null;
+ misskeyApi('announcements/show', {
+ announcementId: props.announcementId,
+ }).then(async _announcement => {
+ announcement.value = _announcement;
+ }).catch(err => {
+ error.value = err;
+ });
+}
+
+async function read(target: Misskey.entities.Announcement): Promise<void> {
+ if (target.needConfirmationToRead) {
+ const confirm = await os.confirm({
+ type: 'question',
+ title: i18n.ts._announcement.readConfirmTitle,
+ text: i18n.tsx._announcement.readConfirmText({ title: target.title }),
+ });
+ if (confirm.canceled) return;
+ }
+
+ target.isRead = true;
+ await misskeyApi('i/read-announcement', { announcementId: target.id });
+ if ($i) {
+ updateAccount({
+ unreadAnnouncements: $i.unreadAnnouncements.filter((a: { id: string; }) => a.id !== target.id),
+ });
+ }
+}
+
+watch(() => path.value, fetch, { immediate: true });
+
+const headerActions = computed(() => []);
+
+const headerTabs = computed(() => []);
+
+definePageMetadata(() => ({
+ title: announcement.value ? `${i18n.ts.announcements}: ${announcement.value.title}` : i18n.ts.announcements,
+ icon: 'ti ti-speakerphone',
+}));
+</script>
+
+<style lang="scss" module>
+.announcement {
+ padding: 16px;
+}
+
+.forYou {
+ display: flex;
+ align-items: center;
+ line-height: 24px;
+ font-size: 90%;
+ white-space: pre;
+ color: #d28a3f;
+}
+
+.header {
+ margin-bottom: 16px;
+ font-weight: bold;
+ font-size: 120%;
+}
+
+.content {
+ > img {
+ display: block;
+ max-height: 300px;
+ max-width: 100%;
+ }
+}
+
+.footer {
+ margin-top: 16px;
+}
+</style>
diff --git a/packages/frontend/src/pages/announcements.vue b/packages/frontend/src/pages/announcements.vue
index bcd6eb7c0f..e50b208775 100644
--- a/packages/frontend/src/pages/announcements.vue
+++ b/packages/frontend/src/pages/announcements.vue
@@ -21,14 +21,19 @@ SPDX-License-Identifier: AGPL-3.0-only
<i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--error);"></i>
<i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--success);"></i>
</span>
- <span>{{ announcement.title }}</span>
+ <MkA :to="`/announcements/${announcement.id}`"><span>{{ announcement.title }}</span></MkA>
</div>
<div :class="$style.content">
<Mfm :text="announcement.text"/>
<img v-if="announcement.imageUrl" :src="announcement.imageUrl"/>
- <div style="opacity: 0.7; font-size: 85%;">
- <MkTime :time="announcement.updatedAt ?? announcement.createdAt" mode="detail"/>
- </div>
+ <MkA :to="`/announcements/${announcement.id}`">
+ <div style="margin-top: 8px; opacity: 0.7; font-size: 85%;">
+ {{ i18n.ts.createdAt }}: <MkTime :time="announcement.createdAt" mode="detail"/>
+ </div>
+ <div v-if="announcement.updatedAt" style="opacity: 0.7; font-size: 85%;">
+ {{ i18n.ts.updatedAt }}: <MkTime :time="announcement.updatedAt" mode="detail"/>
+ </div>
+ </MkA>
</div>
<div v-if="tab !== 'past' && $i && !announcement.silence && !announcement.isRead" :class="$style.footer">
<MkButton primary @click="read(announcement)"><i class="ti ti-check"></i> {{ i18n.ts.gotIt }}</MkButton>
@@ -73,24 +78,24 @@ const paginationEl = ref<InstanceType<typeof MkPagination>>();
const tab = ref('current');
-async function read(announcement) {
- if (announcement.needConfirmationToRead) {
+async function read(target) {
+ if (target.needConfirmationToRead) {
const confirm = await os.confirm({
type: 'question',
title: i18n.ts._announcement.readConfirmTitle,
- text: i18n.tsx._announcement.readConfirmText({ title: announcement.title }),
+ text: i18n.tsx._announcement.readConfirmText({ title: target.title }),
});
if (confirm.canceled) return;
}
if (!paginationEl.value) return;
- paginationEl.value.updateItem(announcement.id, a => {
+ paginationEl.value.updateItem(target.id, a => {
a.isRead = true;
return a;
});
- misskeyApi('i/read-announcement', { announcementId: announcement.id });
+ misskeyApi('i/read-announcement', { announcementId: target.id });
updateAccount({
- unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== announcement.id),
+ unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== target.id),
});
}
diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue
index 611ae6feca..a895df76e8 100644
--- a/packages/frontend/src/pages/channel.vue
+++ b/packages/frontend/src/pages/channel.vue
@@ -83,6 +83,7 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
import { deviceKind } from '@/scripts/device-kind.js';
import MkNotes from '@/components/MkNotes.vue';
import { url } from '@/config.js';
+import { favoritedChannelsCache } from '@/cache.js';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import { defaultStore } from '@/store.js';
@@ -153,6 +154,7 @@ function favorite() {
channelId: channel.value.id,
}).then(() => {
favorited.value = true;
+ favoritedChannelsCache.delete();
});
}
@@ -168,6 +170,7 @@ async function unfavorite() {
channelId: channel.value.id,
}).then(() => {
favorited.value = false;
+ favoritedChannelsCache.delete();
});
}
diff --git a/packages/frontend/src/pages/clip.vue b/packages/frontend/src/pages/clip.vue
index c38cc117bc..fd64a55c65 100644
--- a/packages/frontend/src/pages/clip.vue
+++ b/packages/frontend/src/pages/clip.vue
@@ -9,11 +9,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSpacer :contentMax="800">
<div v-if="clip" class="_gaps">
<div class="_panel">
- <div v-if="clip.description" :class="$style.description">
- <Mfm :text="clip.description" :isNote="false"/>
+ <div class="_gaps_s" :class="$style.description">
+ <div v-if="clip.description">
+ <Mfm :text="clip.description" :isNote="false"/>
+ </div>
+ <div v-else>({{ i18n.ts.noDescription }})</div>
+ <div>
+ <MkButton v-if="favorited" v-tooltip="i18n.ts.unfavorite" asLike rounded primary @click="unfavorite()"><i class="ti ti-heart"></i><span v-if="clip.favoritedCount > 0" style="margin-left: 6px;">{{ clip.favoritedCount }}</span></MkButton>
+ <MkButton v-else v-tooltip="i18n.ts.favorite" asLike rounded @click="favorite()"><i class="ti ti-heart"></i><span v-if="clip.favoritedCount > 0" style="margin-left: 6px;">{{ clip.favoritedCount }}</span></MkButton>
+ </div>
</div>
- <MkButton v-if="favorited" v-tooltip="i18n.ts.unfavorite" asLike rounded primary @click="unfavorite()"><i class="ti ti-heart"></i><span v-if="clip.favoritedCount > 0" style="margin-left: 6px;">{{ clip.favoritedCount }}</span></MkButton>
- <MkButton v-else v-tooltip="i18n.ts.favorite" asLike rounded @click="favorite()"><i class="ti ti-heart"></i><span v-if="clip.favoritedCount > 0" style="margin-left: 6px;">{{ clip.favoritedCount }}</span></MkButton>
<div :class="$style.user">
<MkAvatar :user="clip.user" :class="$style.avatar" indicator link preview/> <MkUserName :user="clip.user" :nowrap="false"/>
</div>
diff --git a/packages/frontend/src/pages/contact.vue b/packages/frontend/src/pages/contact.vue
new file mode 100644
index 0000000000..bcdcf43275
--- /dev/null
+++ b/packages/frontend/src/pages/contact.vue
@@ -0,0 +1,40 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkStickyContainer>
+ <template #header><MkPageHeader/></template>
+ <MkSpacer :contentMax="600" :marginMin="20">
+ <div class="_gaps">
+ <MkKeyValue>
+ <template #key>{{ i18n.ts.inquiry }}</template>
+ <template #value>
+ <MkLink :url="instance.inquiryUrl" target="_blank">{{ instance.inquiryUrl }}</MkLink>
+ </template>
+ </MkKeyValue>
+
+ <MkKeyValue>
+ <template #key>{{ i18n.ts.email }}</template>
+ <template #value>
+ <div>{{ instance.maintainerEmail }}</div>
+ </template>
+ </MkKeyValue>
+ </div>
+ </MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { i18n } from '@/i18n.js';
+import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { instance } from '@/instance.js';
+import MkKeyValue from '@/components/MkKeyValue.vue';
+import MkLink from '@/components/MkLink.vue';
+
+definePageMetadata(() => ({
+ title: i18n.ts.inquiry,
+ icon: 'ti ti-help-circle',
+}));
+</script>
diff --git a/packages/frontend/src/pages/explore.featured.vue b/packages/frontend/src/pages/explore.featured.vue
index b5c8e70166..cfdb235d3a 100644
--- a/packages/frontend/src/pages/explore.featured.vue
+++ b/packages/frontend/src/pages/explore.featured.vue
@@ -29,6 +29,9 @@ const paginationForPolls = {
endpoint: 'notes/polls/recommendation' as const,
limit: 10,
offsetMode: true,
+ params: {
+ excludeChannels: true,
+ },
};
const tab = ref('notes');
diff --git a/packages/frontend/src/pages/flash/flash-edit.vue b/packages/frontend/src/pages/flash/flash-edit.vue
index 4418172e62..3445da26a2 100644
--- a/packages/frontend/src/pages/flash/flash-edit.vue
+++ b/packages/frontend/src/pages/flash/flash-edit.vue
@@ -18,16 +18,17 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkCodeEditor v-model="script" lang="is">
<template #label>{{ i18n.ts._play.script }}</template>
</MkCodeEditor>
- <div class="_buttons">
- <MkButton primary @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
- <MkButton @click="show"><i class="ti ti-eye"></i> {{ i18n.ts.show }}</MkButton>
- <MkButton v-if="flash" danger @click="del"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
- </div>
<MkSelect v-model="visibility">
<template #label>{{ i18n.ts.visibility }}</template>
+ <template #caption>{{ i18n.ts._play.visibilityDescription }}</template>
<option :key="'public'" :value="'public'">{{ i18n.ts.public }}</option>
<option :key="'private'" :value="'private'">{{ i18n.ts.private }}</option>
</MkSelect>
+ <div class="_buttons">
+ <MkButton primary @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
+ <MkButton @click="show"><i class="ti ti-eye"></i> {{ i18n.ts.show }}</MkButton>
+ <MkButton v-if="flash" danger @click="del"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
+ </div>
</div>
</MkSpacer>
</MkStickyContainer>
@@ -47,7 +48,7 @@ import MkInput from '@/components/MkInput.vue';
import MkSelect from '@/components/MkSelect.vue';
import { useRouter } from '@/router/supplier.js';
-const PRESET_DEFAULT = `/// @ 0.16.0
+const PRESET_DEFAULT = `/// @ 0.18.0
var name = ""
@@ -59,13 +60,13 @@ Ui:render([
Ui:C:button({
text: "Hello"
onClick: @() {
- Mk:dialog(null \`Hello, {name}!\`)
+ Mk:dialog(null, \`Hello, {name}!\`)
}
})
])
`;
-const PRESET_OMIKUJI = `/// @ 0.16.0
+const PRESET_OMIKUJI = `/// @ 0.18.0
// ユーザーごとに日替わりのおみくじのプリセット
// 選択肢
@@ -80,11 +81,11 @@ let choices = [
"大凶"
]
-// シードが「ユーザーID+今日の日付」である乱数生成器を用意
-let random = Math:gen_rng(\`{USER_ID}{Date:year()}{Date:month()}{Date:day()}\`)
+// シードが「PlayID+ユーザーID+今日の日付」である乱数生成器を用意
+let random = Math:gen_rng(\`{THIS_ID}{USER_ID}{Date:year()}{Date:month()}{Date:day()}\`)
// ランダムに選択肢を選ぶ
-let chosen = choices[random(0 (choices.len - 1))]
+let chosen = choices[random(0, (choices.len - 1))]
// 結果のテキスト
let result = \`今日のあなたの運勢は **{chosen}** です。\`
@@ -108,7 +109,7 @@ Ui:render([
])
`;
-const PRESET_SHUFFLE = `/// @ 0.16.0
+const PRESET_SHUFFLE = `/// @ 0.18.0
// 巻き戻し可能な文字シャッフルのプリセット
let string = "ペペロンチーノ"
@@ -122,13 +123,13 @@ var cursor = 0
@do() {
if (cursor != 0) {
- results = results.slice(0 (cursor + 1))
+ results = results.slice(0, (cursor + 1))
cursor = 0
}
let chars = []
for (let i, length) {
- let r = Math:rnd(0 (length - 1))
+ let r = Math:rnd(0, (length - 1))
chars.push(string.pick(r))
}
let result = chars.join("")
@@ -162,11 +163,11 @@ var cursor = 0
text: "←"
disabled: !(results.len > 1 && (results.len - cursor) > 1)
onClick: back
- } {
+ }, {
text: "→"
disabled: !(results.len > 1 && cursor > 0)
onClick: forward
- } {
+ }, {
text: "引き直す"
onClick: do
}]
@@ -187,27 +188,27 @@ var cursor = 0
do()
`;
-const PRESET_QUIZ = `/// @ 0.16.0
+const PRESET_QUIZ = `/// @ 0.18.0
let title = '地理クイズ'
let qas = [{
q: 'オーストラリアの首都は?'
- choices: ['シドニー' 'キャンベラ' 'メルボルン']
+ choices: ['シドニー', 'キャンベラ', 'メルボルン']
a: 'キャンベラ'
aDescription: '最大の都市はシドニーですが首都はキャンベラです。'
-} {
+}, {
q: '国土面積2番目の国は?'
- choices: ['カナダ' 'アメリカ' '中国']
+ choices: ['カナダ', 'アメリカ', '中国']
a: 'カナダ'
aDescription: '大きい順にロシア、カナダ、アメリカ、中国です。'
-} {
+}, {
q: '二重内陸国ではないのは?'
- choices: ['リヒテンシュタイン' 'ウズベキスタン' 'レソト']
+ choices: ['リヒテンシュタイン', 'ウズベキスタン', 'レソト']
a: 'レソト'
aDescription: 'レソトは(一重)内陸国です。'
-} {
+}, {
q: '閘門がない運河は?'
- choices: ['キール運河' 'スエズ運河' 'パナマ運河']
+ choices: ['キール運河', 'スエズ運河', 'パナマ運河']
a: 'スエズ運河'
aDescription: 'スエズ運河は高低差がないので閘門はありません。'
}]
@@ -243,9 +244,9 @@ each (let qa, qas) {
})
Ui:C:container({
children: []
- } \`{qa.id}:a\`)
+ }, \`{qa.id}:a\`)
]
- } qa.id))
+ }, qa.id))
}
@finish() {
@@ -295,12 +296,12 @@ qaEls.push(Ui:C:container({
onClick: finish
})
]
-} 'footer'))
+}, 'footer'))
Ui:render(qaEls)
`;
-const PRESET_TIMELINE = `/// @ 0.16.0
+const PRESET_TIMELINE = `/// @ 0.18.0
// APIリクエストを行いローカルタイムラインを表示するプリセット
@fetch() {
@@ -314,7 +315,7 @@ const PRESET_TIMELINE = `/// @ 0.16.0
])
// タイムライン取得
- let notes = Mk:api("notes/local-timeline" {})
+ let notes = Mk:api("notes/local-timeline", {})
// それぞれのノートごとにUI要素作成
let noteEls = []
@@ -367,7 +368,7 @@ const props = defineProps<{
}>();
const flash = ref<Misskey.entities.Flash | null>(null);
-const visibility = ref<Misskey.entities.FlashUpdateRequest['visibility']>('public');
+const visibility = ref<'private' | 'public'>('public');
if (props.id) {
flash.value = await misskeyApi('flash/show', {
@@ -420,6 +421,7 @@ async function save() {
summary: summary.value,
permissions: permissions.value,
script: script.value,
+ visibility: visibility.value,
});
router.push('/play/' + created.id + '/edit');
}
diff --git a/packages/frontend/src/pages/flash/flash.vue b/packages/frontend/src/pages/flash/flash.vue
index 4aa3ce1672..40499fde0e 100644
--- a/packages/frontend/src/pages/flash/flash.vue
+++ b/packages/frontend/src/pages/flash/flash.vue
@@ -15,11 +15,15 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkAsUi v-if="root" :component="root" :components="components"/>
</div>
<div class="actions _panel">
- <MkButton v-if="flash.isLiked" v-tooltip="i18n.ts.unlike" asLike class="button" rounded primary @click="unlike()"><i class="ti ti-heart"></i><span v-if="flash.likedCount > 0" style="margin-left: 6px;">{{ flash.likedCount }}</span></MkButton>
- <MkButton v-else v-tooltip="i18n.ts.like" asLike class="button" rounded @click="like()"><i class="ti ti-heart"></i><span v-if="flash.likedCount > 0" style="margin-left: 6px;">{{ flash.likedCount }}</span></MkButton>
- <MkButton v-tooltip="i18n.ts.shareWithNote" class="button" rounded @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></MkButton>
- <MkButton v-tooltip="i18n.ts.copyLink" class="button" rounded @click="copyLink"><i class="ti ti-link ti-fw"></i></MkButton>
- <MkButton v-if="isSupportShare()" v-tooltip="i18n.ts.share" class="button" rounded @click="share"><i class="ti ti-share ti-fw"></i></MkButton>
+ <div class="items">
+ <MkButton v-tooltip="i18n.ts.reload" class="button" rounded @click="reset"><i class="ti ti-reload"></i></MkButton>
+ </div>
+ <div class="items">
+ <MkButton v-if="flash.isLiked" v-tooltip="i18n.ts.unlike" asLike class="button" rounded primary @click="unlike()"><i class="ti ti-heart"></i><span v-if="flash?.likedCount && flash.likedCount > 0" style="margin-left: 6px;">{{ flash.likedCount }}</span></MkButton>
+ <MkButton v-else v-tooltip="i18n.ts.like" asLike class="button" rounded @click="like()"><i class="ti ti-heart"></i><span v-if="flash?.likedCount && flash.likedCount > 0" style="margin-left: 6px;">{{ flash.likedCount }}</span></MkButton>
+ <MkButton v-tooltip="i18n.ts.copyLink" class="button" rounded @click="copyLink"><i class="ti ti-link ti-fw"></i></MkButton>
+ <MkButton v-tooltip="i18n.ts.share" class="button" rounded @click="share"><i class="ti ti-share ti-fw"></i></MkButton>
+ </div>
</div>
</div>
<div v-else :class="$style.ready">
@@ -49,7 +53,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkA v-if="$i && $i.id === flash.userId" :to="`/play/${flash.id}/edit`" style="color: var(--accent);">{{ i18n.ts._play.editThisPage }}</MkA>
<MkAd :prefer="['horizontal', 'horizontal-big']"/>
</div>
- <MkError v-else-if="error" @retry="fetchPage()"/>
+ <MkError v-else-if="error" @retry="fetchFlash()"/>
<MkLoading v-else/>
</Transition>
</MkSpacer>
@@ -94,12 +98,33 @@ function fetchFlash() {
});
}
+function share(ev: MouseEvent) {
+ if (!flash.value) return;
+
+ os.popupMenu([
+ {
+ text: i18n.ts.shareWithNote,
+ icon: 'ti ti-pencil',
+ action: shareWithNote,
+ },
+ ...(isSupportShare() ? [{
+ text: i18n.ts.share,
+ icon: 'ti ti-share',
+ action: shareWithNavigator,
+ }] : []),
+ ], ev.currentTarget ?? ev.target);
+}
+
function copyLink() {
+ if (!flash.value) return;
+
copyToClipboard(`${url}/play/${flash.value.id}`);
os.success();
}
-function share() {
+function shareWithNavigator() {
+ if (!flash.value) return;
+
navigator.share({
title: flash.value.title,
text: flash.value.summary,
@@ -108,21 +133,28 @@ function share() {
}
function shareWithNote() {
+ if (!flash.value) return;
+
os.post({
- initialText: `${flash.value.title} ${url}/play/${flash.value.id}`,
+ initialText: `${flash.value.title}\n${url}/play/${flash.value.id}`,
+ instant: true,
});
}
function like() {
+ if (!flash.value) return;
+
os.apiWithDialog('flash/like', {
flashId: flash.value.id,
}).then(() => {
- flash.value.isLiked = true;
- flash.value.likedCount++;
+ flash.value!.isLiked = true;
+ flash.value!.likedCount++;
});
}
async function unlike() {
+ if (!flash.value) return;
+
const confirm = await os.confirm({
type: 'warning',
text: i18n.ts.unlikeConfirm,
@@ -131,8 +163,8 @@ async function unlike() {
os.apiWithDialog('flash/unlike', {
flashId: flash.value.id,
}).then(() => {
- flash.value.isLiked = false;
- flash.value.likedCount--;
+ flash.value!.isLiked = false;
+ flash.value!.likedCount--;
});
}
@@ -152,6 +184,7 @@ function start() {
async function run() {
if (aiscript.value) aiscript.value.abort();
+ if (!flash.value) return;
aiscript.value = new Interpreter({
...createAiScriptEnv({
@@ -193,12 +226,17 @@ async function run() {
}
}
-onDeactivated(() => {
+function reset() {
if (aiscript.value) aiscript.value.abort();
+ started.value = false;
+}
+
+onDeactivated(() => {
+ reset();
});
onUnmounted(() => {
- if (aiscript.value) aiscript.value.abort();
+ reset();
});
const headerActions = computed(() => []);
@@ -265,11 +303,19 @@ definePageMetadata(() => ({
}
> .actions {
- display: flex;
- justify-content: center;
- gap: 12px;
margin-top: 16px;
- padding: 16px;
+
+ > .items {
+ display: flex;
+ justify-content: center;
+ gap: 12px;
+ padding: 16px;
+ border-bottom: 1px solid var(--divider);
+
+ &:last-child {
+ border-bottom: none;
+ }
+ }
}
}
}
diff --git a/packages/frontend/src/pages/instance-info.vue b/packages/frontend/src/pages/instance-info.vue
index cb7fe2866c..26797ba85e 100644
--- a/packages/frontend/src/pages/instance-info.vue
+++ b/packages/frontend/src/pages/instance-info.vue
@@ -35,7 +35,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<FormSection v-if="iAmModerator">
<template #label>Moderation</template>
<div class="_gaps_s">
- <MkSwitch v-model="suspended" :disabled="!instance" @update:modelValue="toggleSuspend">{{ i18n.ts.stopActivityDelivery }}</MkSwitch>
+ <MkKeyValue>
+ <template #key>
+ {{ i18n.ts._delivery.status }}
+ </template>
+ <template #value>
+ {{ i18n.ts._delivery._type[suspensionState] }}
+ </template>
+ </MkKeyValue>
+ <MkButton v-if="suspensionState === 'none'" :disabled="!instance" danger @click="stopDelivery">{{ i18n.ts._delivery.stop }}</MkButton>
+ <MkButton v-if="suspensionState !== 'none'" :disabled="!instance" @click="resumeDelivery">{{ i18n.ts._delivery.resume }}</MkButton>
<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>
@@ -155,7 +164,7 @@ const tab = ref('overview');
const chartSrc = ref('instance-requests');
const meta = ref<Misskey.entities.AdminMetaResponse | null>(null);
const instance = ref<Misskey.entities.FederationInstance | null>(null);
-const suspended = ref(false);
+const suspensionState = ref<'none' | 'manuallySuspended' | 'goneSuspended' | 'autoSuspendedForNotResponding'>('none');
const isBlocked = ref(false);
const isSilenced = ref(false);
const faviconUrl = ref<string | null>(null);
@@ -183,7 +192,7 @@ async function fetch(): Promise<void> {
instance.value = await misskeyApi('federation/show-instance', {
host: props.host,
});
- suspended.value = instance.value?.isSuspended ?? false;
+ suspensionState.value = instance.value?.suspensionState ?? 'none';
isBlocked.value = instance.value?.isBlocked ?? false;
isSilenced.value = instance.value?.isSilenced ?? false;
faviconUrl.value = getProxiedImageUrlNullable(instance.value?.faviconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.value?.iconUrl, 'preview');
@@ -209,11 +218,21 @@ async function toggleSilenced(): Promise<void> {
});
}
-async function toggleSuspend(): Promise<void> {
+async function stopDelivery(): Promise<void> {
if (!instance.value) throw new Error('No instance?');
+ suspensionState.value = 'manuallySuspended';
await misskeyApi('admin/federation/update-instance', {
host: instance.value.host,
- isSuspended: suspended.value,
+ isSuspended: true,
+ });
+}
+
+async function resumeDelivery(): Promise<void> {
+ if (!instance.value) throw new Error('No instance?');
+ suspensionState.value = 'none';
+ await misskeyApi('admin/federation/update-instance', {
+ host: instance.value.host,
+ isSuspended: false,
});
}
diff --git a/packages/frontend/src/pages/my-antennas/create.vue b/packages/frontend/src/pages/my-antennas/create.vue
index 8b3b3cfbfd..2d026d2fa9 100644
--- a/packages/frontend/src/pages/my-antennas/create.vue
+++ b/packages/frontend/src/pages/my-antennas/create.vue
@@ -26,6 +26,7 @@ const draft = ref({
users: [],
keywords: [],
excludeKeywords: [],
+ excludeBots: false,
withReplies: false,
caseSensitive: false,
localOnly: false,
diff --git a/packages/frontend/src/pages/my-antennas/editor.vue b/packages/frontend/src/pages/my-antennas/editor.vue
index c6dcbadd9b..2949bfc02c 100644
--- a/packages/frontend/src/pages/my-antennas/editor.vue
+++ b/packages/frontend/src/pages/my-antennas/editor.vue
@@ -26,6 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts.users }}</template>
<template #caption>{{ i18n.ts.antennaUsersDescription }} <button class="_textButton" @click="addUser">{{ i18n.ts.addUser }}</button></template>
</MkTextarea>
+ <MkSwitch v-model="excludeBots">{{ i18n.ts.antennaExcludeBots }}</MkSwitch>
<MkSwitch v-model="withReplies">{{ i18n.ts.withReplies }}</MkSwitch>
<MkTextarea v-model="keywords">
<template #label>{{ i18n.ts.antennaKeywords }}</template>
@@ -38,7 +39,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSwitch v-model="localOnly">{{ i18n.ts.localOnly }}</MkSwitch>
<MkSwitch v-model="caseSensitive">{{ i18n.ts.caseSensitive }}</MkSwitch>
<MkSwitch v-model="withFile">{{ i18n.ts.withFileAntenna }}</MkSwitch>
- <MkSwitch v-model="notify">{{ i18n.ts.notifyAntenna }}</MkSwitch>
</div>
<div :class="$style.actions">
<MkButton inline primary @click="saveAntenna()"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
@@ -78,9 +78,9 @@ const keywords = ref<string>(props.antenna.keywords.map(x => x.join(' ')).join('
const excludeKeywords = ref<string>(props.antenna.excludeKeywords.map(x => x.join(' ')).join('\n'));
const caseSensitive = ref<boolean>(props.antenna.caseSensitive);
const localOnly = ref<boolean>(props.antenna.localOnly);
+const excludeBots = ref<boolean>(props.antenna.excludeBots);
const withReplies = ref<boolean>(props.antenna.withReplies);
const withFile = ref<boolean>(props.antenna.withFile);
-const notify = ref<boolean>(props.antenna.notify);
const userLists = ref<Misskey.entities.UserList[] | null>(null);
watch(() => src.value, async () => {
@@ -94,9 +94,9 @@ async function saveAntenna() {
name: name.value,
src: src.value,
userListId: userListId.value,
+ excludeBots: excludeBots.value,
withReplies: withReplies.value,
withFile: withFile.value,
- notify: notify.value,
caseSensitive: caseSensitive.value,
localOnly: localOnly.value,
users: users.value.trim().split('\n').map(x => x.trim()),
diff --git a/packages/frontend/src/pages/my-clips/index.vue b/packages/frontend/src/pages/my-clips/index.vue
index 803b28899a..1a0d7177fc 100644
--- a/packages/frontend/src/pages/my-clips/index.vue
+++ b/packages/frontend/src/pages/my-clips/index.vue
@@ -11,16 +11,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="tab === 'my'" key="my" class="_gaps">
<MkButton primary rounded class="add" @click="create"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
- <MkPagination v-slot="{items}" ref="pagingComponent" :pagination="pagination" class="_gaps">
- <MkA v-for="item in items" :key="item.id" :to="`/clips/${item.id}`">
- <MkClipPreview :clip="item"/>
- </MkA>
+ <MkPagination v-slot="{ items }" ref="pagingComponent" :pagination="pagination" class="_gaps">
+ <MkClipPreview v-for="item in items" :key="item.id" :clip="item"/>
</MkPagination>
</div>
<div v-else-if="tab === 'favorites'" key="favorites" class="_gaps">
- <MkA v-for="item in favorites" :key="item.id" :to="`/clips/${item.id}`">
- <MkClipPreview :clip="item"/>
- </MkA>
+ <MkClipPreview v-for="item in favorites" :key="item.id" :clip="item"/>
</div>
</MkHorizontalSwipe>
</MkSpacer>
diff --git a/packages/frontend/src/pages/note.vue b/packages/frontend/src/pages/note.vue
index 4c985b96e6..97f32d35cd 100644
--- a/packages/frontend/src/pages/note.vue
+++ b/packages/frontend/src/pages/note.vue
@@ -21,14 +21,12 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div class="_margin _gaps_s">
<MkRemoteCaution v-if="note.user.host != null" :href="note.url ?? note.uri"/>
- <MkNoteDetailed :key="note.id" v-model:note="note" :class="$style.note"/>
+ <MkNoteDetailed :key="note.id" v-model:note="note" :initialTab="initialTab" :class="$style.note"/>
</div>
<div v-if="clips && clips.length > 0" class="_margin">
<div style="font-weight: bold; padding: 12px;">{{ i18n.ts.clip }}</div>
<div class="_gaps">
- <MkA v-for="item in clips" :key="item.id" :to="`/clips/${item.id}`">
- <MkClipPreview :clip="item"/>
- </MkA>
+ <MkClipPreview v-for="item in clips" :key="item.id" :clip="item"/>
</div>
</div>
<div v-if="!showPrev" class="_buttons" :class="$style.loadPrev">
@@ -66,6 +64,7 @@ import { defaultStore } from '@/store.js';
const props = defineProps<{
noteId: string;
+ initialTab?: string;
}>();
const note = ref<null | Misskey.entities.Note>();
diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue
index 194a276f89..0a28386986 100644
--- a/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue
+++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue
@@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<XContainer :draggable="true" @remove="() => $emit('remove')">
<template #header><i class="ti ti-note"></i> {{ i18n.ts._pages.blocks.note }}</template>
- <section style="padding: 0 16px 0 16px;">
+ <section style="padding: 16px;" class="_gaps_s">
<MkInput v-model="id">
<template #label>{{ i18n.ts._pages.blocks._note.id }}</template>
<template #caption>{{ i18n.ts._pages.blocks._note.idDescription }}</template>
diff --git a/packages/frontend/src/pages/page.vue b/packages/frontend/src/pages/page.vue
index bece32fc11..e73d032000 100644
--- a/packages/frontend/src/pages/page.vue
+++ b/packages/frontend/src/pages/page.vue
@@ -6,48 +6,80 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :contentMax="700">
- <Transition :name="defaultStore.state.animation ? 'fade' : ''" mode="out-in">
- <div v-if="page" :key="page.id" class="xcukqgmh">
- <div class="main">
- <!--
- <div class="header">
- <h1>{{ page.title }}</h1>
- </div>
- -->
- <div class="banner">
- <MkMediaImage
- v-if="page.eyeCatchingImageId"
- :image="page.eyeCatchingImage"
- :cover="true"
- :disableImageLink="true"
- class="thumbnail"
- />
+ <MkSpacer :contentMax="800">
+ <Transition
+ :enterActiveClass="defaultStore.state.animation ? $style.fadeEnterActive : ''"
+ :leaveActiveClass="defaultStore.state.animation ? $style.fadeLeaveActive : ''"
+ :enterFromClass="defaultStore.state.animation ? $style.fadeEnterFrom : ''"
+ :leaveToClass="defaultStore.state.animation ? $style.fadeLeaveTo : ''"
+ mode="out-in"
+ >
+ <div v-if="page" :key="page.id" class="_gaps">
+ <div :class="$style.pageMain">
+ <div :class="$style.pageBanner">
+ <div :class="$style.pageBannerBgRoot">
+ <MkImgWithBlurhash
+ v-if="page.eyeCatchingImageId"
+ :class="$style.pageBannerBg"
+ :hash="page.eyeCatchingImage?.blurhash"
+ :cover="true"
+ :forceBlurhash="true"
+ />
+ <img
+ v-else-if="instance.backgroundImageUrl || instance.bannerUrl"
+ :class="[$style.pageBannerBg, $style.pageBannerBgFallback1]"
+ :src="getStaticImageUrl(instance.backgroundImageUrl ?? instance.bannerUrl!)"
+ />
+ <div v-else :class="[$style.pageBannerBg, $style.pageBannerBgFallback2]"></div>
+ </div>
+ <div v-if="page.eyeCatchingImageId" :class="$style.pageBannerImage">
+ <MkMediaImage
+ :image="page.eyeCatchingImage!"
+ :cover="true"
+ :disableImageLink="true"
+ :class="$style.thumbnail"
+ />
+ </div>
+ <div :class="$style.pageBannerTitle" class="_gaps_s">
+ <h1>{{ page.title || page.name }}</h1>
+ <div :class="$style.pageBannerTitleSub">
+ <div v-if="page.user" :class="$style.pageBannerTitleUser">
+ <MkAvatar :user="page.user" :class="$style.avatar" indicator link preview/> <MkA :to="`/@${username}`"><MkUserName :user="page.user" :nowrap="false"/></MkA>
+ </div>
+ <div :class="$style.pageBannerTitleSubActions">
+ <MkA v-if="page.userId === $i?.id" v-tooltip="i18n.ts._pages.editThisPage" :to="`/pages/edit/${page.id}`" class="_button" :class="$style.generalActionButton"><i class="ti ti-pencil ti-fw"></i></MkA>
+ <button v-tooltip="i18n.ts.share" class="_button" :class="$style.generalActionButton" @click="share"><i class="ti ti-share ti-fw"></i></button>
+ </div>
+ </div>
+ </div>
</div>
- <div class="content">
+ <div :class="$style.pageContent">
<XPage :page="page"/>
</div>
- <div class="actions">
- <div class="like">
+ <div :class="$style.pageActions">
+ <div>
<MkButton v-if="page.isLiked" v-tooltip="i18n.ts._pages.unlike" class="button" asLike primary @click="unlike()"><i class="ti ti-heart-off"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton>
<MkButton v-else v-tooltip="i18n.ts._pages.like" class="button" asLike @click="like()"><i class="ti ti-heart"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton>
</div>
- <div class="other">
- <button v-tooltip="i18n.ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></button>
- <button v-tooltip="i18n.ts.copyLink" v-click-anime class="_button" @click="copyLink"><i class="ti ti-link ti-fw"></i></button>
- <button v-if="isSupportShare()" v-tooltip="i18n.ts.share" v-click-anime class="_button" @click="share"><i class="ti ti-share ti-fw"></i></button>
+ <div :class="$style.other">
+ <button v-tooltip="i18n.ts.copyLink" class="_button" :class="$style.generalActionButton" @click="copyLink"><i class="ti ti-link ti-fw"></i></button>
+ <button v-tooltip="i18n.ts.share" class="_button" :class="$style.generalActionButton" @click="share"><i class="ti ti-share ti-fw"></i></button>
</div>
</div>
- <div class="user">
- <MkAvatar :user="page.user" class="avatar" link preview/>
- <div class="name">
- <MkUserName :user="page.user" style="display: block;"/>
- <MkAcct :user="page.user"/>
- </div>
- <MkFollowButton v-if="!$i || $i.id != page.user.id" :user="page.user" :inline="true" :transparent="false" :full="true" large class="koudoku"/>
+ <div :class="$style.pageUser">
+ <MkAvatar :user="page.user" :class="$style.avatar" link preview/>
+ <MkA :to="`/@${username}`">
+ <MkUserName :user="page.user" :class="$style.name"/>
+ <MkAcct :user="page.user" :class="$style.acct"/>
+ </MkA>
+ <MkFollowButton v-if="!$i || $i.id != page.user.id" :user="page.user!" :inline="true" :transparent="false" :full="true" :class="$style.follow"/>
</div>
- <div class="links">
- <MkA :to="`/@${username}/pages/${pageName}/view-source`" class="link">{{ i18n.ts._pages.viewSource }}</MkA>
+ <div :class="$style.pageDate">
+ <div><i class="ti ti-clock"></i> {{ i18n.ts.createdAt }}: <MkTime :time="page.createdAt" mode="detail"/></div>
+ <div v-if="page.createdAt != page.updatedAt"><i class="ti ti-clock-edit"></i> {{ i18n.ts.updatedAt }}: <MkTime :time="page.updatedAt" mode="detail"/></div>
+ </div>
+ <div :class="$style.pageLinks">
+ <MkA v-if="!$i || $i.id !== page.userId" :to="`/@${username}/pages/${pageName}/view-source`" class="link">{{ i18n.ts._pages.viewSource }}</MkA>
<template v-if="$i && $i.id === page.userId">
<MkA :to="`/pages/edit/${page.id}`" class="link">{{ i18n.ts._pages.editThisPage }}</MkA>
<button v-if="$i.pinnedPageId === page.id" class="link _textButton" @click="pin(false)">{{ i18n.ts.unpin }}</button>
@@ -55,10 +87,6 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
</div>
</div>
- <div class="footer">
- <div><i class="ti ti-clock"></i> {{ i18n.ts.createdAt }}: <MkTime :time="page.createdAt" mode="detail"/></div>
- <div v-if="page.createdAt != page.updatedAt"><i class="ti ti-clock"></i> {{ i18n.ts.updatedAt }}: <MkTime :time="page.updatedAt" mode="detail"/></div>
- </div>
<MkAd :prefer="['horizontal', 'horizontal-big']"/>
<MkContainer :max-height="300" :foldable="true" class="other">
<template #icon><i class="ti ti-clock"></i></template>
@@ -84,6 +112,7 @@ import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { url } from '@/config.js';
import MkMediaImage from '@/components/MkMediaImage.vue';
+import MkImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
import MkFollowButton from '@/components/MkFollowButton.vue';
import MkContainer from '@/components/MkContainer.vue';
import MkPagination from '@/components/MkPagination.vue';
@@ -94,6 +123,8 @@ import { pageViewInterruptors, defaultStore } from '@/store.js';
import { deepClone } from '@/scripts/clone.js';
import { $i } from '@/account.js';
import { isSupportShare } from '@/scripts/navigator.js';
+import { instance } from '@/instance.js';
+import { getStaticImageUrl } from '@/scripts/media-proxy.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
const props = defineProps<{
@@ -133,35 +164,63 @@ function fetchPage() {
});
}
-function share() {
- navigator.share({
- title: page.value.title ?? page.value.name,
- text: page.value.summary,
- url: `${url}/@${page.value.user.username}/pages/${page.value.name}`,
- });
+function share(ev: MouseEvent) {
+ if (!page.value) return;
+
+ os.popupMenu([
+ {
+ text: i18n.ts.shareWithNote,
+ icon: 'ti ti-pencil',
+ action: shareWithNote,
+ },
+ ...(isSupportShare() ? [{
+ text: i18n.ts.share,
+ icon: 'ti ti-share',
+ action: shareWithNavigator,
+ }] : []),
+ ], ev.currentTarget ?? ev.target);
}
function copyLink() {
+ if (!page.value) return;
+
copyToClipboard(`${url}/@${page.value.user.username}/pages/${page.value.name}`);
os.success();
}
function shareWithNote() {
+ if (!page.value) return;
+
os.post({
- initialText: `${page.value.title || page.value.name} ${url}/@${page.value.user.username}/pages/${page.value.name}`,
+ initialText: `${page.value.title || page.value.name}\n${url}/@${page.value.user.username}/pages/${page.value.name}`,
+ instant: true,
+ });
+}
+
+function shareWithNavigator() {
+ if (!page.value) return;
+
+ navigator.share({
+ title: page.value.title ?? page.value.name,
+ text: page.value.summary ?? undefined,
+ url: `${url}/@${page.value.user.username}/pages/${page.value.name}`,
});
}
function like() {
+ if (!page.value) return;
+
os.apiWithDialog('pages/like', {
pageId: page.value.id,
}).then(() => {
- page.value.isLiked = true;
- page.value.likedCount++;
+ page.value!.isLiked = true;
+ page.value!.likedCount++;
});
}
async function unlike() {
+ if (!page.value) return;
+
const confirm = await os.confirm({
type: 'warning',
text: i18n.ts.unlikeConfirm,
@@ -170,12 +229,14 @@ async function unlike() {
os.apiWithDialog('pages/unlike', {
pageId: page.value.id,
}).then(() => {
- page.value.isLiked = false;
- page.value.likedCount--;
+ page.value!.isLiked = false;
+ page.value!.likedCount--;
});
}
function pin(pin) {
+ if (!page.value) return;
+
os.apiWithDialog('i/update', {
pinnedPageId: pin ? page.value.id : null,
});
@@ -200,109 +261,200 @@ definePageMetadata(() => ({
}));
</script>
-<style lang="scss" scoped>
-.fade-enter-active,
-.fade-leave-active {
+<style lang="scss" module>
+.fadeEnterActive,
+.fadeLeaveActive {
transition: opacity 0.125s ease;
}
-.fade-enter-from,
-.fade-leave-to {
+.fadeEnterFrom,
+.fadeLeaveTo {
opacity: 0;
}
-.xcukqgmh {
- > .main {
- padding: 32px;
+.generalActionButton {
+ height: 2.5rem;
+ width: 2.5rem;
+ text-align: center;
+ border-radius: 99rem;
- > .header {
- padding: 16px;
+ & :global(.ti) {
+ line-height: 2.5rem;
+ }
- > h1 {
- margin: 0;
- }
+ &:hover,
+ &:focus-visible {
+ background-color: var(--accentedBg);
+ color: var(--accent);
+ text-decoration: none;
+ }
+}
+
+.pageMain {
+ border-radius: var(--radius);
+ padding: 2rem;
+ background: var(--panel);
+ box-sizing: border-box;
+}
+
+.pageBanner {
+ width: calc(100% + 4rem);
+ margin: -2rem -2rem 1.5rem;
+ border-radius: var(--radius) var(--radius) 0 0;
+ overflow: hidden;
+ position: relative;
+
+ > .pageBannerBgRoot {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+
+ .pageBannerBg {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ opacity: .2;
+ filter: brightness(1.2);
}
- > .banner {
- > .thumbnail {
- // TODO: 良い感じのアスペクト比で表示
- display: block;
- width: 100%;
- height: auto;
- aspect-ratio: 3/1;
- border-radius: var(--radius);
- overflow: hidden;
- object-fit: cover;
- }
+ .pageBannerBgFallback1 {
+ filter: blur(20px);
}
- > .content {
- margin-top: 16px;
- padding: 16px 0 0 0;
+ .pageBannerBgFallback2 {
+ background-color: var(--accentedBg);
}
- > .actions {
- display: flex;
- align-items: center;
- margin-top: 16px;
- padding: 16px 0 0 0;
- border-top: solid 0.5px var(--divider);
+ &::after {
+ content: '';
+ position: absolute;
+ left: 0;
+ bottom: 0;
+ width: 100%;
+ height: 100px;
+ background: linear-gradient(0deg, var(--panel), transparent);
+ }
+ }
- > .other {
- margin-left: auto;
+ > .pageBannerImage {
+ position: relative;
+ padding-top: 56.25%;
- > button {
- padding: 8px;
- margin: 0 8px;
+ > .thumbnail {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ }
+ }
- &:hover {
- color: var(--fgHighlighted);
- }
- }
- }
+ > .pageBannerTitle {
+ position: relative;
+ padding: 1.5rem 2rem;
+
+ h1 {
+ font-size: 2rem;
+ font-weight: 700;
+ color: var(--fg);
+ margin: 0;
}
- > .user {
- margin-top: 16px;
- padding: 16px 0 0 0;
- border-top: solid 0.5px var(--divider);
+ .pageBannerTitleSub {
display: flex;
align-items: center;
+ width: 100%;
+ }
- > .avatar {
- width: 52px;
- height: 52px;
- }
+ .pageBannerTitleUser {
+ --height: 32px;
+ flex-shrink: 0;
- > .name {
- margin: 0 0 0 12px;
- font-size: 90%;
+ .avatar {
+ height: var(--height);
+ width: var(--height);
}
- > .koudoku {
- margin-left: auto;
- }
+ line-height: var(--height);
}
- > .links {
- margin-top: 16px;
- padding: 24px 0 0 0;
- border-top: solid 0.5px var(--divider);
-
- > .link {
- margin-right: 0.75em;
- }
+ .pageBannerTitleSubActions {
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ gap: var(--marginHalf);
+ margin-left: auto;
}
}
+}
+
+.pageContent {
+ margin-bottom: 1.5rem;
+}
+
+.pageActions {
+ display: flex;
+ align-items: center;
+
+ border-top: 1px solid var(--divider);
+ padding-top: 1.5rem;
+ margin-bottom: 1.5rem;
+
+ > .other {
+ margin-left: auto;
+ display: flex;
+ gap: var(--marginHalf);
+ }
+}
+
+.pageUser {
+ display: flex;
+ align-items: center;
+
+ border-top: 1px solid var(--divider);
+ padding-top: 1.5rem;
+ margin-bottom: 1.5rem;
+
+ .avatar,
+ .name,
+ .acct {
+ display: block;
+ }
+
+ .avatar {
+ width: 4rem;
+ height: 4rem;
+ margin-right: 1rem;
+ }
- > .footer {
- margin: var(--margin) 0 var(--margin) 0;
- font-size: 85%;
- opacity: 0.75;
+ .name {
+ font-size: 110%;
+ font-weight: 700;
+ }
+
+ .acct {
+ font-size: 90%;
+ opacity: 0.7;
+ }
+
+ .follow {
+ margin-left: auto;
}
}
-</style>
-<style module>
+.pageDate {
+ margin-bottom: 1.5rem;
+}
+
+.pageLinks {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: var(--marginHalf);
+}
+
.relatedPagesRoot {
padding: var(--margin);
}
diff --git a/packages/frontend/src/pages/reversi/game.board.vue b/packages/frontend/src/pages/reversi/game.board.vue
index 5259dfa29a..175ea62411 100644
--- a/packages/frontend/src/pages/reversi/game.board.vue
+++ b/packages/frontend/src/pages/reversi/game.board.vue
@@ -151,6 +151,7 @@ import MkSwitch from '@/components/MkSwitch.vue';
import { deepClone } from '@/scripts/clone.js';
import { useInterval } from '@/scripts/use-interval.js';
import { signinRequired } from '@/account.js';
+import { url } from '@/config.js';
import { i18n } from '@/i18n.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { userPage } from '@/filters/user.js';
@@ -442,7 +443,7 @@ function autoplay() {
function share() {
os.post({
- initialText: `#MisskeyReversi ${location.href}`,
+ initialText: `#MisskeyReversi\n${url}/reversi/g/${game.value.id}`,
instant: true,
});
}
diff --git a/packages/frontend/src/pages/settings/2fa.qrdialog.vue b/packages/frontend/src/pages/settings/2fa.qrdialog.vue
index 2608560cc4..2244047b31 100644
--- a/packages/frontend/src/pages/settings/2fa.qrdialog.vue
+++ b/packages/frontend/src/pages/settings/2fa.qrdialog.vue
@@ -25,6 +25,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<div style="height: 100cqh; overflow: auto; text-align: center;">
<MkSpacer :marginMin="20" :marginMax="28">
<div class="_gaps">
+ <MkInfo><MkLink url="https://misskey-hub.net/docs/for-users/stepped-guides/how-to-enable-2fa/" target="_blank">{{ i18n.ts._2fa.moreDetailedGuideHere }}</MkLink></MkInfo>
+
<I18n :src="i18n.ts._2fa.step1" tag="div">
<template #a>
<a href="https://authy.com/" rel="noopener" target="_blank" class="_link">Authy</a>
@@ -33,8 +35,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<a href="https://support.google.com/accounts/answer/1066447" rel="noopener" target="_blank" class="_link">Google Authenticator</a>
</template>
</I18n>
- <div>{{ i18n.ts._2fa.step2 }}<br>{{ i18n.ts._2fa.step2Click }}</div>
- <a :href="twoFactorData.url"><img :class="$style.qr" :src="twoFactorData.qr"></a>
+ <div>{{ i18n.ts._2fa.step2 }}</div>
+ <div>
+ <a :class="$style.qrRoot" :href="twoFactorData.url"><img :class="$style.qr" :src="twoFactorData.qr"></a>
+ <!-- QRコード側にマージンが入っているので直下でOK -->
+ <div><MkButton inline rounded link :to="twoFactorData.url" :linkBehavior="'browser'">{{ i18n.ts.launchApp }}</MkButton></div>
+ </div>
<MkKeyValue :copy="twoFactorData.url">
<template #key>{{ i18n.ts._2fa.step2Uri }}</template>
<template #value>{{ twoFactorData.url }}</template>
@@ -52,7 +58,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSpacer :marginMin="20" :marginMax="28">
<div class="_gaps">
<div>{{ i18n.ts._2fa.step3Title }}</div>
- <MkInput v-model="token" autocomplete="one-time-code"></MkInput>
+ <MkInput v-model="token" autocomplete="one-time-code" inputmode="numeric"></MkInput>
<div>{{ i18n.ts._2fa.step3 }}</div>
</div>
<div class="_buttonsCenter" style="margin-top: 16px;">
@@ -109,6 +115,7 @@ import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import MkFolder from '@/components/MkFolder.vue';
import MkInfo from '@/components/MkInfo.vue';
+import MkLink from '@/components/MkLink.vue';
import { confetti } from '@/scripts/confetti.js';
import { signinRequired } from '@/account.js';
@@ -177,8 +184,14 @@ function allDone() {
transform: translateX(-50px);
}
-.qr {
+.qrRoot {
+ display: block;
+ margin: 0 auto;
width: 200px;
max-width: 100%;
}
+
+.qr {
+ width: 100%;
+}
</style>
diff --git a/packages/frontend/src/pages/settings/2fa.vue b/packages/frontend/src/pages/settings/2fa.vue
index d8c5f848fe..b7d648c1a4 100644
--- a/packages/frontend/src/pages/settings/2fa.vue
+++ b/packages/frontend/src/pages/settings/2fa.vue
@@ -30,7 +30,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton v-else danger @click="unregisterTOTP">{{ i18n.ts.unregister }}</MkButton>
</div>
- <MkButton v-else-if="!$i.twoFactorEnabled" primary gradate @click="registerTOTP">{{ i18n.ts._2fa.registerTOTP }}</MkButton>
+ <div v-else-if="!$i.twoFactorEnabled" class="_gaps_s">
+ <MkButton primary gradate @click="registerTOTP">{{ i18n.ts._2fa.registerTOTP }}</MkButton>
+ <MkLink url="https://misskey-hub.net/docs/for-users/stepped-guides/how-to-enable-2fa/" target="_blank"><i class="ti ti-help-circle"></i> {{ i18n.ts.learnMore }}</MkLink>
+ </div>
</MkFolder>
<MkFolder>
@@ -79,8 +82,9 @@ import MkInfo from '@/components/MkInfo.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import FormSection from '@/components/form/section.vue';
import MkFolder from '@/components/MkFolder.vue';
+import MkLink from '@/components/MkLink.vue';
import * as os from '@/os.js';
-import { signinRequired } from '@/account.js';
+import { signinRequired, updateAccount } from '@/account.js';
import { i18n } from '@/i18n.js';
const $i = signinRequired();
@@ -116,6 +120,10 @@ async function unregisterTOTP(): Promise<void> {
os.apiWithDialog('i/2fa/unregister', {
password: auth.result.password,
token: auth.result.token,
+ }).then(res => {
+ updateAccount({
+ twoFactorEnabled: false,
+ });
}).catch(error => {
os.alert({
type: 'error',
diff --git a/packages/frontend/src/pages/settings/drive.vue b/packages/frontend/src/pages/settings/drive.vue
index 1919f80864..81a8d474d2 100644
--- a/packages/frontend/src/pages/settings/drive.vue
+++ b/packages/frontend/src/pages/settings/drive.vue
@@ -44,6 +44,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts.keepOriginalUploading }}</template>
<template #caption>{{ i18n.ts.keepOriginalUploadingDescription }}</template>
</MkSwitch>
+ <MkSwitch v-model="keepOriginalFilename">
+ <template #label>{{ i18n.ts.keepOriginalFilename }}</template>
+ <template #caption>{{ i18n.ts.keepOriginalFilenameDescription }}</template>
+ </MkSwitch>
<MkSwitch v-model="alwaysMarkNsfw" @update:modelValue="saveProfile()">
<template #label>{{ i18n.ts.alwaysMarkSensitive }}</template>
</MkSwitch>
@@ -96,6 +100,7 @@ const meterStyle = computed(() => {
});
const keepOriginalUploading = computed(defaultStore.makeGetterSetter('keepOriginalUploading'));
+const keepOriginalFilename = computed(defaultStore.makeGetterSetter('keepOriginalFilename'));
misskeyApi('drive').then(info => {
capacity.value = info.capacity;
diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue
index d13b6884bd..cfc63f2a08 100644
--- a/packages/frontend/src/pages/settings/general.vue
+++ b/packages/frontend/src/pages/settings/general.vue
@@ -50,12 +50,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps_m">
<div class="_gaps_s">
+ <MkSwitch v-model="collapseRenotes">
+ <template #label>{{ i18n.ts.collapseRenotes }}</template>
+ <template #caption>{{ i18n.ts.collapseRenotesDescription }}</template>
+ </MkSwitch>
<MkSwitch v-model="showNoteActionsOnlyHover">{{ i18n.ts.showNoteActionsOnlyHover }}</MkSwitch>
<MkSwitch v-model="showClipButtonInNoteFooter">{{ i18n.ts.showClipButtonInNoteFooter }}</MkSwitch>
- <MkSwitch v-model="collapseRenotes">{{ i18n.ts.collapseRenotes }}</MkSwitch>
<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">
@@ -131,6 +135,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSwitch v-model="disableDrawer">{{ i18n.ts.disableDrawer }}</MkSwitch>
<MkSwitch v-model="forceShowAds">{{ i18n.ts.forceShowAds }}</MkSwitch>
<MkSwitch v-model="enableSeasonalScreenEffect">{{ i18n.ts.seasonalScreenEffect }}</MkSwitch>
+ <MkSwitch v-model="useNativeUIForVideoAudioPlayer">{{ i18n.ts.useNativeUIForVideoAudioPlayer }}</MkSwitch>
</div>
<div>
<MkRadios v-model="emojiStyle">
@@ -163,6 +168,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSwitch v-model="keepScreenOn">{{ i18n.ts.keepScreenOn }}</MkSwitch>
<MkSwitch v-model="disableStreamingTimeline">{{ i18n.ts.disableStreamingTimeline }}</MkSwitch>
<MkSwitch v-model="enableHorizontalSwipe">{{ i18n.ts.enableHorizontalSwipe }}</MkSwitch>
+ <MkSwitch v-model="alwaysConfirmFollow">{{ i18n.ts.alwaysConfirmFollow }}</MkSwitch>
</div>
<MkSelect v-model="serverDisconnectedBehavior">
<template #label>{{ i18n.ts.whenServerDisconnected }}</template>
@@ -281,6 +287,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'));
@@ -306,6 +313,8 @@ const disableStreamingTimeline = computed(defaultStore.makeGetterSetter('disable
const useGroupedNotifications = computed(defaultStore.makeGetterSetter('useGroupedNotifications'));
const enableSeasonalScreenEffect = computed(defaultStore.makeGetterSetter('enableSeasonalScreenEffect'));
const enableHorizontalSwipe = computed(defaultStore.makeGetterSetter('enableHorizontalSwipe'));
+const useNativeUIForVideoAudioPlayer = computed(defaultStore.makeGetterSetter('useNativeUIForVideoAudioPlayer'));
+const alwaysConfirmFollow = computed(defaultStore.makeGetterSetter('alwaysConfirmFollow'));
watch(lang, () => {
miLocalStorage.setItem('lang', lang.value as string);
@@ -347,6 +356,7 @@ watch([
keepScreenOn,
disableStreamingTimeline,
enableSeasonalScreenEffect,
+ alwaysConfirmFollow,
], async () => {
await reloadAsk();
});
diff --git a/packages/frontend/src/pages/settings/plugin.vue b/packages/frontend/src/pages/settings/plugin.vue
index 0ab75b95a2..9804454e66 100644
--- a/packages/frontend/src/pages/settings/plugin.vue
+++ b/packages/frontend/src/pages/settings/plugin.vue
@@ -42,12 +42,25 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<MkFolder>
+ <template #icon><i class="ti ti-terminal-2"></i></template>
+ <template #label>{{ i18n.ts._plugin.viewLog }}</template>
+
+ <div class="_gaps_s">
+ <div class="_buttons">
+ <MkButton inline @click="copy(pluginLogs.get(plugin.id)?.join('\n'))"><i class="ti ti-copy"></i> {{ i18n.ts.copy }}</MkButton>
+ </div>
+
+ <MkCode :code="pluginLogs.get(plugin.id)?.join('\n') ?? ''"/>
+ </div>
+ </MkFolder>
+
+ <MkFolder>
<template #icon><i class="ti ti-code"></i></template>
<template #label>{{ i18n.ts._plugin.viewSource }}</template>
<div class="_gaps_s">
<div class="_buttons">
- <MkButton inline @click="copy(plugin)"><i class="ti ti-copy"></i> {{ i18n.ts.copy }}</MkButton>
+ <MkButton inline @click="copy(plugin.src)"><i class="ti ti-copy"></i> {{ i18n.ts.copy }}</MkButton>
</div>
<MkCode :code="plugin.src ?? ''" lang="is"/>
@@ -74,6 +87,7 @@ import { ColdDeviceStorage } from '@/store.js';
import { unisonReload } from '@/scripts/unison-reload.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { pluginLogs } from '@/plugin.js';
const plugins = ref(ColdDeviceStorage.get('plugins'));
@@ -87,8 +101,8 @@ async function uninstall(plugin) {
});
}
-function copy(plugin) {
- copyToClipboard(plugin.src ?? '');
+function copy(text) {
+ copyToClipboard(text ?? '');
os.success();
}
diff --git a/packages/frontend/src/pages/settings/preferences-backups.vue b/packages/frontend/src/pages/settings/preferences-backups.vue
index 942de19d82..b6f1043154 100644
--- a/packages/frontend/src/pages/settings/preferences-backups.vue
+++ b/packages/frontend/src/pages/settings/preferences-backups.vue
@@ -70,6 +70,7 @@ const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [
'animation',
'animatedMfm',
'advancedMfm',
+ 'showReactionsCount',
'loadRawImages',
'imageNewTab',
'dataSaver',
diff --git a/packages/frontend/src/pages/share.vue b/packages/frontend/src/pages/share.vue
index 680934e7ce..37f6558d64 100644
--- a/packages/frontend/src/pages/share.vue
+++ b/packages/frontend/src/pages/share.vue
@@ -64,7 +64,34 @@ async function init() {
// Googleニュース対策
if (text?.startsWith(`${title.value}.\n`)) noteText += text.replace(`${title.value}.\n`, '');
else if (text && title.value !== text) noteText += `${text}\n`;
- if (url) noteText += `${url}`;
+ if (url) {
+ try {
+ // Normalize the URL to URL-encoded and puny-coded from with the URL constructor.
+ //
+ // It's common to use unicode characters in the URL for better visibility of URL
+ // like: https://ja.wikipedia.org/wiki/ミスキー
+ // or like: https://藍.moe/
+ // However, in the MFM, the unicode characters must be URL-encoded to be parsed as `url` node
+ // like: https://ja.wikipedia.org/wiki/%E3%83%9F%E3%82%B9%E3%82%AD%E3%83%BC
+ // or like: https://xn--931a.moe/
+ // Therefore, we need to normalize the URL to URL-encoded form.
+ //
+ // The URL constructor will parse the URL and normalize unicode characters
+ // in the host to punycode and in the path component to URL-encoded form.
+ // (see url.spec.whatwg.org)
+ //
+ // In addition, the current MFM renderer decodes the URL-encoded path and / punycode encoded host name so
+ // this normalization doesn't make the visible URL ugly.
+ // (see MkUrl.vue)
+
+ noteText += new URL(url).href;
+ } catch {
+ // fallback to original URL if the URL is invalid.
+ // note that this is extremely rare since the `url` parameter is designed to share a URL and
+ // the URL constructor will throw TypeError only if failure, which means the URL is not valid.
+ noteText += url;
+ }
+ }
initialText.value = noteText.trim();
if (visibility.value === 'specified') {
diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue
index 48dfc1fd44..98744c6318 100644
--- a/packages/frontend/src/pages/timeline.vue
+++ b/packages/frontend/src/pages/timeline.vue
@@ -48,7 +48,7 @@ import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js';
import { $i } from '@/account.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
-import { antennasCache, userListsCache } from '@/cache.js';
+import { antennasCache, userListsCache, favoritedChannelsCache } from '@/cache.js';
import { deviceKind } from '@/scripts/device-kind.js';
import { deepMerge } from '@/scripts/merge.js';
import { MenuItem } from '@/types/menu.js';
@@ -173,9 +173,7 @@ async function chooseAntenna(ev: MouseEvent): Promise<void> {
}
async function chooseChannel(ev: MouseEvent): Promise<void> {
- const channels = await misskeyApi('channels/my-favorites', {
- limit: 100,
- });
+ const channels = await favoritedChannelsCache.fetch();
const items: MenuItem[] = [
...channels.map(channel => {
const lastReadedAt = miLocalStorage.getItemAsJson(`channelLastReadedAt:${channel.id}`) ?? null;
diff --git a/packages/frontend/src/pages/welcome.entrance.a.vue b/packages/frontend/src/pages/welcome.entrance.a.vue
index 89bb010dd6..d6ba397f1b 100644
--- a/packages/frontend/src/pages/welcome.entrance.a.vue
+++ b/packages/frontend/src/pages/welcome.entrance.a.vue
@@ -9,7 +9,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<XTimeline class="tl"/>
<div class="shape1"></div>
<div class="shape2"></div>
- <img :src="misskeysvg" class="misskey"/>
+ <div class="logo-wrapper">
+ <div class="powered-by">Powered by</div>
+ <img :src="misskeysvg" class="misskey"/>
+ </div>
<div class="emojis">
<MkEmoji :normal="true" :noStyle="true" emoji="👍"/>
<MkEmoji :normal="true" :noStyle="true" emoji="❤"/>
@@ -39,11 +42,11 @@ import XTimeline from './welcome.timeline.vue';
import MarqueeText from '@/components/MkMarquee.vue';
import MkFeaturedPhotos from '@/components/MkFeaturedPhotos.vue';
import misskeysvg from '/client-assets/misskey.svg';
-import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js';
+import { misskeyApiGet } from '@/scripts/misskey-api.js';
import MkVisitorDashboard from '@/components/MkVisitorDashboard.vue';
import { getProxiedImageUrl } from '@/scripts/media-proxy.js';
+import { instance as meta } from '@/instance.js';
-const meta = ref<Misskey.entities.MetaResponse>();
const instances = ref<Misskey.entities.FederationInstance[]>();
function getInstanceIcon(instance: Misskey.entities.FederationInstance): string {
@@ -53,10 +56,6 @@ function getInstanceIcon(instance: Misskey.entities.FederationInstance): string
return getProxiedImageUrl(instance.iconUrl, 'preview');
}
-misskeyApi('meta', { detail: true }).then(_meta => {
- meta.value = _meta;
-});
-
misskeyApiGet('federation/instances', {
sort: '+pubSub',
limit: 20,
@@ -113,14 +112,24 @@ misskeyApiGet('federation/instances', {
opacity: 0.5;
}
- > .misskey {
+ > .logo-wrapper {
position: fixed;
- top: 42px;
- left: 42px;
- width: 140px;
+ top: 36px;
+ left: 36px;
+ flex: auto;
+ color: #fff;
+ user-select: none;
+ pointer-events: none;
+
+ > .powered-by {
+ margin-bottom: 2px;
+ }
- @media (max-width: 450px) {
- width: 130px;
+ > .misskey {
+ width: 140px;
+ @media (max-width: 450px) {
+ width: 130px;
+ }
}
}
diff --git a/packages/frontend/src/pages/welcome.vue b/packages/frontend/src/pages/welcome.vue
index 9ba6a5885e..915fe35025 100644
--- a/packages/frontend/src/pages/welcome.vue
+++ b/packages/frontend/src/pages/welcome.vue
@@ -4,8 +4,8 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
-<div v-if="meta">
- <XSetup v-if="meta.requireSetup"/>
+<div v-if="instance">
+ <XSetup v-if="instance.requireSetup"/>
<XEntrance v-else/>
</div>
</template>
@@ -16,13 +16,13 @@ import * as Misskey from 'misskey-js';
import XSetup from './welcome.setup.vue';
import XEntrance from './welcome.entrance.a.vue';
import { instanceName } from '@/config.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { fetchInstance } from '@/instance.js';
-const meta = ref<Misskey.entities.MetaResponse | null>(null);
+const instance = ref<Misskey.entities.MetaDetailed | null>(null);
-misskeyApi('meta', { detail: true }).then(res => {
- meta.value = res;
+fetchInstance(true).then((res) => {
+ instance.value = res;
});
const headerActions = computed(() => []);
diff --git a/packages/frontend/src/plugin.ts b/packages/frontend/src/plugin.ts
index 743cadc36a..81233a5a5e 100644
--- a/packages/frontend/src/plugin.ts
+++ b/packages/frontend/src/plugin.ts
@@ -3,6 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
+import { ref } from 'vue';
import { Interpreter, Parser, utils, values } from '@syuilo/aiscript';
import { aiScriptReadline, createAiScriptEnv } from '@/scripts/aiscript/api.js';
import { inputText } from '@/os.js';
@@ -10,6 +11,7 @@ import { Plugin, noteActions, notePostInterruptors, noteViewInterruptors, postFo
const parser = new Parser();
const pluginContexts = new Map<string, Interpreter>();
+export const pluginLogs = ref(new Map<string, string[]>());
export async function install(plugin: Plugin): Promise<void> {
// 後方互換性のため
@@ -22,21 +24,27 @@ export async function install(plugin: Plugin): Promise<void> {
in: aiScriptReadline,
out: (value): void => {
console.log(value);
+ pluginLogs.value.get(plugin.id).push(utils.reprValue(value));
},
log: (): void => {
},
+ err: (err): void => {
+ pluginLogs.value.get(plugin.id).push(`${err}`);
+ throw err; // install時のtry-catchに反応させる
+ },
});
initPlugin({ plugin, aiscript });
- try {
- await aiscript.exec(parser.parse(plugin.src));
- } catch (err) {
- console.error('Plugin install failed:', plugin.name, 'v' + plugin.version);
- return;
- }
-
- console.info('Plugin installed:', plugin.name, 'v' + plugin.version);
+ aiscript.exec(parser.parse(plugin.src)).then(
+ () => {
+ console.info('Plugin installed:', plugin.name, 'v' + plugin.version);
+ },
+ (err) => {
+ console.error('Plugin install failed:', plugin.name, 'v' + plugin.version);
+ throw err;
+ },
+ );
}
function createPluginEnv(opts: { plugin: Plugin; storageKey: string }): Record<string, values.Value> {
@@ -92,6 +100,7 @@ function createPluginEnv(opts: { plugin: Plugin; storageKey: string }): Record<s
function initPlugin({ plugin, aiscript }): void {
pluginContexts.set(plugin.id, aiscript);
+ pluginLogs.value.set(plugin.id, []);
}
function registerPostFormAction({ pluginId, title, handler }): void {
diff --git a/packages/frontend/src/router/definition.ts b/packages/frontend/src/router/definition.ts
index eaeeafd499..c12ae0fa57 100644
--- a/packages/frontend/src/router/definition.ts
+++ b/packages/frontend/src/router/definition.ts
@@ -35,7 +35,7 @@ const routes: RouteDef[] = [{
component: page(() => import('@/pages/user/index.vue')),
}, {
name: 'note',
- path: '/notes/:noteId',
+ path: '/notes/:noteId/:initialTab?',
component: page(() => import('@/pages/note.vue')),
}, {
name: 'list',
@@ -194,10 +194,16 @@ const routes: RouteDef[] = [{
path: '/announcements',
component: page(() => import('@/pages/announcements.vue')),
}, {
+ path: '/announcements/:announcementId',
+ component: page(() => import('@/pages/announcement.vue')),
+}, {
path: '/about',
component: page(() => import('@/pages/about.vue')),
hash: 'initialTab',
}, {
+ path: '/contact',
+ component: page(() => import('@/pages/contact.vue')),
+}, {
path: '/about-misskey',
component: page(() => import('@/pages/about-misskey.vue')),
}, {
diff --git a/packages/frontend/src/scripts/lookup-user.ts b/packages/frontend/src/scripts/admin-lookup.ts
index efc9132e75..1b57b853c9 100644
--- a/packages/frontend/src/scripts/lookup-user.ts
+++ b/packages/frontend/src/scripts/admin-lookup.ts
@@ -63,3 +63,26 @@ export async function lookupUserByEmail() {
}
}
}
+
+export async function lookupFile() {
+ const { canceled, result: q } = await os.inputText({
+ title: i18n.ts.fileIdOrUrl,
+ minLength: 1,
+ });
+ if (canceled) return;
+
+ const show = (file) => {
+ os.pageWindow(`/admin/file/${file.id}`);
+ };
+
+ misskeyApi('admin/drive/show-file', q.startsWith('http://') || q.startsWith('https://') ? { url: q.trim() } : { fileId: q.trim() }).then(file => {
+ show(file);
+ }).catch(err => {
+ if (err.code === 'NO_SUCH_FILE') {
+ os.alert({
+ type: 'error',
+ text: i18n.ts.notFound,
+ });
+ }
+ });
+}
diff --git a/packages/frontend/src/scripts/aiscript/ui.ts b/packages/frontend/src/scripts/aiscript/ui.ts
index f2493264d3..fa3fcac2e7 100644
--- a/packages/frontend/src/scripts/aiscript/ui.ts
+++ b/packages/frontend/src/scripts/aiscript/ui.ts
@@ -6,6 +6,7 @@
import { utils, values } from '@syuilo/aiscript';
import { v4 as uuid } from 'uuid';
import { ref, Ref } from 'vue';
+import * as Misskey from 'misskey-js';
export type AsUiComponentBase = {
id: string;
@@ -115,23 +116,24 @@ export type AsUiFolder = AsUiComponentBase & {
opened?: boolean;
};
+type PostFormPropsForAsUi = {
+ text: string;
+ cw?: string;
+ visibility?: (typeof Misskey.noteVisibilities)[number];
+ localOnly?: boolean;
+};
+
export type AsUiPostFormButton = AsUiComponentBase & {
type: 'postFormButton';
text?: string;
primary?: boolean;
rounded?: boolean;
- form?: {
- text: string;
- cw?: string;
- };
+ form?: PostFormPropsForAsUi;
};
export type AsUiPostForm = AsUiComponentBase & {
type: 'postForm';
- form?: {
- text: string;
- cw?: string;
- };
+ form?: PostFormPropsForAsUi;
};
export type AsUiComponent = AsUiRoot | AsUiContainer | AsUiText | AsUiMfm | AsUiButton | AsUiButtons | AsUiSwitch | AsUiTextarea | AsUiTextInput | AsUiNumberInput | AsUiSelect | AsUiFolder | AsUiPostFormButton | AsUiPostForm;
@@ -447,6 +449,24 @@ function getFolderOptions(def: values.Value | undefined): Omit<AsUiFolder, 'id'
};
}
+function getPostFormProps(form: values.VObj): PostFormPropsForAsUi {
+ const text = form.value.get('text');
+ utils.assertString(text);
+ const cw = form.value.get('cw');
+ if (cw) utils.assertString(cw);
+ const visibility = form.value.get('visibility');
+ if (visibility) utils.assertString(visibility);
+ const localOnly = form.value.get('localOnly');
+ if (localOnly) utils.assertBoolean(localOnly);
+
+ return {
+ text: text.value,
+ cw: cw?.value,
+ visibility: (visibility?.value && (Misskey.noteVisibilities as readonly string[]).includes(visibility.value)) ? visibility.value as typeof Misskey.noteVisibilities[number] : undefined,
+ localOnly: localOnly?.value,
+ };
+}
+
function getPostFormButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiPostFormButton, 'id' | 'type'> {
utils.assertObject(def);
@@ -459,22 +479,11 @@ function getPostFormButtonOptions(def: values.Value | undefined, call: (fn: valu
const form = def.value.get('form');
if (form) utils.assertObject(form);
- const getForm = () => {
- const text = form!.value.get('text');
- utils.assertString(text);
- const cw = form!.value.get('cw');
- if (cw) utils.assertString(cw);
- return {
- text: text.value,
- cw: cw?.value,
- };
- };
-
return {
text: text?.value,
primary: primary?.value,
rounded: rounded?.value,
- form: form ? getForm() : {
+ form: form ? getPostFormProps(form) : {
text: '',
},
};
@@ -486,19 +495,8 @@ function getPostFormOptions(def: values.Value | undefined, call: (fn: values.VFn
const form = def.value.get('form');
if (form) utils.assertObject(form);
- const getForm = () => {
- const text = form!.value.get('text');
- utils.assertString(text);
- const cw = form!.value.get('cw');
- if (cw) utils.assertString(cw);
- return {
- text: text.value,
- cw: cw?.value,
- };
- };
-
return {
- form: form ? getForm() : {
+ form: form ? getPostFormProps(form) : {
text: '',
},
};
diff --git a/packages/frontend/src/scripts/check-reaction-permissions.ts b/packages/frontend/src/scripts/check-reaction-permissions.ts
index e7b473dd75..8fc857f84f 100644
--- a/packages/frontend/src/scripts/check-reaction-permissions.ts
+++ b/packages/frontend/src/scripts/check-reaction-permissions.ts
@@ -1,3 +1,8 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
import * as Misskey from 'misskey-js';
import { UnicodeEmojiDef } from './emojilist.js';
diff --git a/packages/frontend/src/scripts/clear-cache.ts b/packages/frontend/src/scripts/clear-cache.ts
index b20109ec72..71d1232710 100644
--- a/packages/frontend/src/scripts/clear-cache.ts
+++ b/packages/frontend/src/scripts/clear-cache.ts
@@ -1,3 +1,8 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
import { unisonReload } from '@/scripts/unison-reload.js';
import * as os from '@/os.js';
import { miLocalStorage } from '@/local-storage.js';
diff --git a/packages/frontend/src/scripts/code-highlighter.ts b/packages/frontend/src/scripts/code-highlighter.ts
index 2733897bab..e94027d302 100644
--- a/packages/frontend/src/scripts/code-highlighter.ts
+++ b/packages/frontend/src/scripts/code-highlighter.ts
@@ -1,15 +1,21 @@
-import { bundledThemesInfo } from 'shiki';
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
import { getHighlighterCore, loadWasm } from 'shiki/core';
import darkPlus from 'shiki/themes/dark-plus.mjs';
+import { bundledThemesInfo } from 'shiki/themes';
+import { bundledLanguagesInfo } from 'shiki/langs';
import { unique } from './array.js';
import { deepClone } from './clone.js';
import { deepMerge } from './merge.js';
-import type { Highlighter, LanguageRegistration, ThemeRegistration, ThemeRegistrationRaw } from 'shiki';
+import type { HighlighterCore, LanguageRegistration, ThemeRegistration, ThemeRegistrationRaw } from 'shiki/core';
import { ColdDeviceStorage } from '@/store.js';
import lightTheme from '@/themes/_light.json5';
import darkTheme from '@/themes/_dark.json5';
-let _highlighter: Highlighter | null = null;
+let _highlighter: HighlighterCore | null = null;
export async function getTheme(mode: 'light' | 'dark', getName: true): Promise<string>;
export async function getTheme(mode: 'light' | 'dark', getName?: false): Promise<ThemeRegistration | ThemeRegistrationRaw>;
@@ -46,16 +52,14 @@ export async function getTheme(mode: 'light' | 'dark', getName = false): Promise
return darkPlus;
}
-export async function getHighlighter(): Promise<Highlighter> {
+export async function getHighlighter(): Promise<HighlighterCore> {
if (!_highlighter) {
return await initHighlighter();
}
return _highlighter;
}
-export async function initHighlighter() {
- const aiScriptGrammar = await import('aiscript-vscode/aiscript/syntaxes/aiscript.tmLanguage.json');
-
+async function initHighlighter() {
await loadWasm(import('shiki/onig.wasm?init'));
// テーマの重複を消す
@@ -64,11 +68,12 @@ export async function initHighlighter() {
...(await Promise.all([getTheme('light'), getTheme('dark')])),
]);
+ const jsLangInfo = bundledLanguagesInfo.find(t => t.id === 'javascript');
const highlighter = await getHighlighterCore({
themes,
langs: [
- import('shiki/langs/javascript.mjs'),
- aiScriptGrammar.default as unknown as LanguageRegistration,
+ ...(jsLangInfo ? [async () => await jsLangInfo.import()] : []),
+ async () => (await import('aiscript-vscode/aiscript/syntaxes/aiscript.tmLanguage.json')).default as unknown as LanguageRegistration,
],
});
diff --git a/packages/frontend/src/scripts/collapsed.ts b/packages/frontend/src/scripts/collapsed.ts
index 237bd37c7a..4ec88a3c65 100644
--- a/packages/frontend/src/scripts/collapsed.ts
+++ b/packages/frontend/src/scripts/collapsed.ts
@@ -6,15 +6,16 @@
import * as Misskey from 'misskey-js';
export function shouldCollapsed(note: Misskey.entities.Note, urls: string[]): boolean {
- const collapsed = note.cw == null && note.text != null && (
- (note.text.includes('$[x2')) ||
- (note.text.includes('$[x3')) ||
- (note.text.includes('$[x4')) ||
- (note.text.includes('$[scale')) ||
- (note.text.split('\n').length > 9) ||
- (note.text.length > 500) ||
- (note.files.length >= 5) ||
- (urls.length >= 4)
+ const collapsed = note.cw == null && (
+ note.text != null && (
+ (note.text.includes('$[x2')) ||
+ (note.text.includes('$[x3')) ||
+ (note.text.includes('$[x4')) ||
+ (note.text.includes('$[scale')) ||
+ (note.text.split('\n').length > 9) ||
+ (note.text.length > 500) ||
+ (urls.length >= 4)
+ ) || note.files.length >= 5
);
return collapsed;
diff --git a/packages/frontend/src/scripts/form.ts b/packages/frontend/src/scripts/form.ts
index b0db404f28..242a504c3b 100644
--- a/packages/frontend/src/scripts/form.ts
+++ b/packages/frontend/src/scripts/form.ts
@@ -3,18 +3,22 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
+import * as Misskey from 'misskey-js';
+
type EnumItem = string | {
label: string;
value: string;
};
+type Hidden = boolean | ((v: any) => boolean);
+
export type FormItem = {
label?: string;
type: 'string';
default: string | null;
description?: string;
required?: boolean;
- hidden?: boolean;
+ hidden?: Hidden;
multiline?: boolean;
treatAsMfm?: boolean;
} | {
@@ -23,27 +27,27 @@ export type FormItem = {
default: number | null;
description?: string;
required?: boolean;
- hidden?: boolean;
+ hidden?: Hidden;
step?: number;
} | {
label?: string;
type: 'boolean';
default: boolean | null;
description?: string;
- hidden?: boolean;
+ hidden?: Hidden;
} | {
label?: string;
type: 'enum';
default: string | null;
required?: boolean;
- hidden?: boolean;
+ hidden?: Hidden;
enum: EnumItem[];
} | {
label?: string;
type: 'radio';
default: unknown | null;
required?: boolean;
- hidden?: boolean;
+ hidden?: Hidden;
options: {
label: string;
value: unknown;
@@ -58,20 +62,27 @@ export type FormItem = {
min: number;
max: number;
textConverter?: (value: number) => string;
+ hidden?: Hidden;
} | {
label?: string;
type: 'object';
default: Record<string, unknown> | null;
- hidden: boolean;
+ hidden: Hidden;
} | {
label?: string;
type: 'array';
default: unknown[] | null;
- hidden: boolean;
+ hidden: Hidden;
} | {
type: 'button';
content?: string;
+ hidden?: Hidden;
action: (ev: MouseEvent, v: any) => void;
+} | {
+ type: 'drive-file';
+ defaultFileId?: string | null;
+ hidden?: Hidden;
+ validate?: (v: Misskey.entities.DriveFile) => Promise<boolean>;
};
export type Form = Record<string, FormItem>;
@@ -84,8 +95,9 @@ type GetItemType<Item extends FormItem> =
Item['type'] extends 'range' ? number :
Item['type'] extends 'enum' ? string :
Item['type'] extends 'array' ? unknown[] :
- Item['type'] extends 'object' ? Record<string, unknown>
- : never;
+ Item['type'] extends 'object' ? Record<string, unknown> :
+ Item['type'] extends 'drive-file' ? Misskey.entities.DriveFile | undefined :
+ never;
export type GetFormResultType<F extends Form> = {
[P in keyof F]: GetItemType<F[P]>;
diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/scripts/get-note-menu.ts
index b273bd36f3..71ad299f50 100644
--- a/packages/frontend/src/scripts/get-note-menu.ts
+++ b/packages/frontend/src/scripts/get-note-menu.ts
@@ -16,7 +16,7 @@ import { url } from '@/config.js';
import { defaultStore, noteActions } from '@/store.js';
import { miLocalStorage } from '@/local-storage.js';
import { getUserMenu } from '@/scripts/get-user-menu.js';
-import { clipsCache } from '@/cache.js';
+import { clipsCache, favoritedChannelsCache } from '@/cache.js';
import { MenuItem } from '@/types/menu.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { isSupportShare } from '@/scripts/navigator.js';
@@ -26,6 +26,14 @@ export async function getNoteClipMenu(props: {
isDeleted: Ref<boolean>;
currentClip?: Misskey.entities.Clip;
}) {
+ function getClipName(clip: Misskey.entities.Clip) {
+ if ($i && clip.userId === $i.id && clip.notesCount != null) {
+ return `${clip.name} (${clip.notesCount}/${$i.policies.noteEachClipsLimit})`;
+ } else {
+ return clip.name;
+ }
+ }
+
const isRenote = (
props.note.renote != null &&
props.note.text == null &&
@@ -37,7 +45,7 @@ export async function getNoteClipMenu(props: {
const clips = await clipsCache.fetch();
const menu: MenuItem[] = [...clips.map(clip => ({
- text: clip.name,
+ text: getClipName(clip),
action: () => {
claimAchievement('noteClipped1');
os.promiseDialog(
@@ -50,7 +58,18 @@ export async function getNoteClipMenu(props: {
text: i18n.tsx.confirmToUnclipAlreadyClippedNote({ name: clip.name }),
});
if (!confirm.canceled) {
- os.apiWithDialog('clips/remove-note', { clipId: clip.id, noteId: appearNote.id });
+ os.apiWithDialog('clips/remove-note', { clipId: clip.id, noteId: appearNote.id }).then(() => {
+ clipsCache.set(clips.map(c => {
+ if (c.id === clip.id) {
+ return {
+ ...c,
+ notesCount: Math.max(0, ((c.notesCount ?? 0) - 1)),
+ };
+ } else {
+ return c;
+ }
+ }));
+ });
if (props.currentClip?.id === clip.id) props.isDeleted.value = true;
}
} else {
@@ -60,7 +79,18 @@ export async function getNoteClipMenu(props: {
});
}
},
- );
+ ).then(() => {
+ clipsCache.set(clips.map(c => {
+ if (c.id === clip.id) {
+ return {
+ ...c,
+ notesCount: (c.notesCount ?? 0) + 1,
+ };
+ } else {
+ return c;
+ }
+ }));
+ });
},
})), { type: 'divider' }, {
icon: 'ti ti-plus',
@@ -462,10 +492,9 @@ export function getNoteMenu(props: {
};
}
-type Visibility = 'public' | 'home' | 'followers' | 'specified';
+type Visibility = (typeof Misskey.noteVisibilities)[number];
-// defaultStore.state.visibilityがstringなためstringも受け付けている
-function smallerVisibility(a: Visibility | string, b: Visibility | string): Visibility {
+function smallerVisibility(a: Visibility, b: Visibility): Visibility {
if (a === 'specified' || b === 'specified') return 'specified';
if (a === 'followers' || b === 'followers') return 'followers';
if (a === 'home' || b === 'home') return 'home';
@@ -489,6 +518,7 @@ export function getRenoteMenu(props: {
const channelRenoteItems: MenuItem[] = [];
const normalRenoteItems: MenuItem[] = [];
+ const normalExternalChannelRenoteItems: MenuItem[] = [];
if (appearNote.channel) {
channelRenoteItems.push(...[{
@@ -567,12 +597,47 @@ export function getRenoteMenu(props: {
});
},
}]);
+
+ normalExternalChannelRenoteItems.push({
+ type: 'parent',
+ icon: 'ti ti-repeat',
+ text: appearNote.channel ? i18n.ts.renoteToOtherChannel : i18n.ts.renoteToChannel,
+ children: async () => {
+ const channels = await favoritedChannelsCache.fetch();
+ return channels.filter((channel) => {
+ if (!appearNote.channelId) return true;
+ return channel.id !== appearNote.channelId;
+ }).map((channel) => ({
+ text: channel.name,
+ action: () => {
+ const el = props.renoteButton.value;
+ if (el) {
+ const rect = el.getBoundingClientRect();
+ const x = rect.left + (el.offsetWidth / 2);
+ const y = rect.top + (el.offsetHeight / 2);
+ os.popup(MkRippleEffect, { x, y }, {}, 'end');
+ }
+
+ if (!props.mock) {
+ misskeyApi('notes/create', {
+ renoteId: appearNote.id,
+ channelId: channel.id,
+ }).then(() => {
+ os.toast(i18n.tsx.renotedToX({ name: channel.name }));
+ });
+ }
+ },
+ }));
+ },
+ });
}
const renoteItems = [
...normalRenoteItems,
...(channelRenoteItems.length > 0 && normalRenoteItems.length > 0) ? [{ type: 'divider' }] as MenuItem[] : [],
...channelRenoteItems,
+ ...(normalExternalChannelRenoteItems.length > 0 && (normalRenoteItems.length > 0 || channelRenoteItems.length > 0)) ? [{ type: 'divider' }] as MenuItem[] : [],
+ ...normalExternalChannelRenoteItems,
];
return {
diff --git a/packages/frontend/src/scripts/get-user-menu.ts b/packages/frontend/src/scripts/get-user-menu.ts
index c14f75f382..3e031d232f 100644
--- a/packages/frontend/src/scripts/get-user-menu.ts
+++ b/packages/frontend/src/scripts/get-user-menu.ts
@@ -272,7 +272,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter
text: r.name,
action: async () => {
const { canceled, result: period } = await os.select({
- title: i18n.ts.period,
+ title: i18n.ts.period + ': ' + r.name,
items: [{
value: 'indefinitely', text: i18n.ts.indefinitely,
}, {
diff --git a/packages/frontend/src/scripts/idb-proxy.ts b/packages/frontend/src/scripts/idb-proxy.ts
index 1ca0990ba9..6b511f2a5f 100644
--- a/packages/frontend/src/scripts/idb-proxy.ts
+++ b/packages/frontend/src/scripts/idb-proxy.ts
@@ -15,6 +15,16 @@ const fallbackName = (key: string) => `idbfallback::${key}`;
let idbAvailable = typeof window !== 'undefined' ? !!(window.indexedDB && window.indexedDB.open) : true;
+// iframe.contentWindow.indexedDB.deleteDatabase() がchromeのバグで使用できないため、indexedDBを無効化している。
+// バグが治って再度有効化するのであれば、cypressのコマンド内のコメントアウトを外すこと
+// see https://github.com/misskey-dev/misskey/issues/13605#issuecomment-2053652123
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-expect-error
+if (window.Cypress) {
+ idbAvailable = false;
+ console.log('Cypress detected. It will use localStorage.');
+}
+
if (idbAvailable) {
await iset('idb-test', 'test')
.catch(err => {
diff --git a/packages/frontend/src/scripts/keycode.ts b/packages/frontend/src/scripts/keycode.ts
index bc1f485f5e..7ffceafada 100644
--- a/packages/frontend/src/scripts/keycode.ts
+++ b/packages/frontend/src/scripts/keycode.ts
@@ -15,6 +15,7 @@ export default (input: string): string[] => {
export const aliases = {
'esc': 'Escape',
'enter': ['Enter', 'NumpadEnter'],
+ 'space': [' ', 'Spacebar'],
'up': 'ArrowUp',
'down': 'ArrowDown',
'left': 'ArrowLeft',
diff --git a/packages/frontend/src/scripts/media-has-audio.ts b/packages/frontend/src/scripts/media-has-audio.ts
index 3421a38a76..4bf3ee5d97 100644
--- a/packages/frontend/src/scripts/media-has-audio.ts
+++ b/packages/frontend/src/scripts/media-has-audio.ts
@@ -1,3 +1,8 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
export default async function hasAudio(media: HTMLMediaElement) {
const cloned = media.cloneNode() as HTMLMediaElement;
cloned.muted = (cloned as typeof cloned & Partial<HTMLVideoElement>).playsInline = true;
diff --git a/packages/frontend/src/scripts/popup-position.ts b/packages/frontend/src/scripts/popup-position.ts
index 8c9e3c02c3..3dad41a8b3 100644
--- a/packages/frontend/src/scripts/popup-position.ts
+++ b/packages/frontend/src/scripts/popup-position.ts
@@ -26,8 +26,8 @@ export function calcPopupPosition(el: HTMLElement, props: {
let top: number;
if (props.anchorElement) {
- left = rect.left + window.pageXOffset + (props.anchorElement.offsetWidth / 2);
- top = (rect.top + window.pageYOffset - contentHeight) - props.innerMargin;
+ left = rect.left + window.scrollX + (props.anchorElement.offsetWidth / 2);
+ top = (rect.top + window.scrollY - contentHeight) - props.innerMargin;
} else {
left = props.x;
top = (props.y - contentHeight) - props.innerMargin;
@@ -35,8 +35,8 @@ export function calcPopupPosition(el: HTMLElement, props: {
left -= (el.offsetWidth / 2);
- if (left + contentWidth - window.pageXOffset > window.innerWidth) {
- left = window.innerWidth - contentWidth + window.pageXOffset - 1;
+ if (left + contentWidth - window.scrollX > window.innerWidth) {
+ left = window.innerWidth - contentWidth + window.scrollX - 1;
}
return [left, top];
@@ -47,8 +47,8 @@ export function calcPopupPosition(el: HTMLElement, props: {
let top: number;
if (props.anchorElement) {
- left = rect.left + window.pageXOffset + (props.anchorElement.offsetWidth / 2);
- top = (rect.top + window.pageYOffset + props.anchorElement.offsetHeight) + props.innerMargin;
+ left = rect.left + window.scrollX + (props.anchorElement.offsetWidth / 2);
+ top = (rect.top + window.scrollY + props.anchorElement.offsetHeight) + props.innerMargin;
} else {
left = props.x;
top = (props.y) + props.innerMargin;
@@ -56,8 +56,8 @@ export function calcPopupPosition(el: HTMLElement, props: {
left -= (el.offsetWidth / 2);
- if (left + contentWidth - window.pageXOffset > window.innerWidth) {
- left = window.innerWidth - contentWidth + window.pageXOffset - 1;
+ if (left + contentWidth - window.scrollX > window.innerWidth) {
+ left = window.innerWidth - contentWidth + window.scrollX - 1;
}
return [left, top];
@@ -68,8 +68,8 @@ export function calcPopupPosition(el: HTMLElement, props: {
let top: number;
if (props.anchorElement) {
- left = (rect.left + window.pageXOffset - contentWidth) - props.innerMargin;
- top = rect.top + window.pageYOffset + (props.anchorElement.offsetHeight / 2);
+ left = (rect.left + window.scrollX - contentWidth) - props.innerMargin;
+ top = rect.top + window.scrollY + (props.anchorElement.offsetHeight / 2);
} else {
left = (props.x - contentWidth) - props.innerMargin;
top = props.y;
@@ -77,8 +77,8 @@ export function calcPopupPosition(el: HTMLElement, props: {
top -= (el.offsetHeight / 2);
- if (top + contentHeight - window.pageYOffset > window.innerHeight) {
- top = window.innerHeight - contentHeight + window.pageYOffset - 1;
+ if (top + contentHeight - window.scrollY > window.innerHeight) {
+ top = window.innerHeight - contentHeight + window.scrollY - 1;
}
return [left, top];
@@ -89,15 +89,15 @@ export function calcPopupPosition(el: HTMLElement, props: {
let top: number;
if (props.anchorElement) {
- left = (rect.left + props.anchorElement.offsetWidth + window.pageXOffset) + props.innerMargin;
+ left = (rect.left + props.anchorElement.offsetWidth + window.scrollX) + props.innerMargin;
if (props.align === 'top') {
- top = rect.top + window.pageYOffset;
+ top = rect.top + window.scrollY;
if (props.alignOffset != null) top += props.alignOffset;
} else if (props.align === 'bottom') {
// TODO
} else { // center
- top = rect.top + window.pageYOffset + (props.anchorElement.offsetHeight / 2);
+ top = rect.top + window.scrollY + (props.anchorElement.offsetHeight / 2);
top -= (el.offsetHeight / 2);
}
} else {
@@ -106,8 +106,8 @@ export function calcPopupPosition(el: HTMLElement, props: {
top -= (el.offsetHeight / 2);
}
- if (top + contentHeight - window.pageYOffset > window.innerHeight) {
- top = window.innerHeight - contentHeight + window.pageYOffset - 1;
+ if (top + contentHeight - window.scrollY > window.innerHeight) {
+ top = window.innerHeight - contentHeight + window.scrollY - 1;
}
return [left, top];
@@ -123,7 +123,7 @@ export function calcPopupPosition(el: HTMLElement, props: {
const [left, top] = calcPosWhenTop();
// ツールチップを上に向かって表示するスペースがなければ下に向かって出す
- if (top - window.pageYOffset < 0) {
+ if (top - window.scrollY < 0) {
const [left, top] = calcPosWhenBottom();
return { left, top, transformOrigin: 'center top' };
}
@@ -141,7 +141,7 @@ export function calcPopupPosition(el: HTMLElement, props: {
const [left, top] = calcPosWhenLeft();
// ツールチップを左に向かって表示するスペースがなければ右に向かって出す
- if (left - window.pageXOffset < 0) {
+ if (left - window.scrollX < 0) {
const [left, top] = calcPosWhenRight();
return { left, top, transformOrigin: 'left center' };
}
diff --git a/packages/frontend/src/scripts/snowfall-effect.ts b/packages/frontend/src/scripts/snowfall-effect.ts
index 11fcaa0716..d88bdb6660 100644
--- a/packages/frontend/src/scripts/snowfall-effect.ts
+++ b/packages/frontend/src/scripts/snowfall-effect.ts
@@ -155,7 +155,9 @@ export class SnowfallEffect {
max: 0.125,
easing: 0.0005,
};
-
+ /**
+ * @throws {Error} - Thrown when it fails to get WebGL context for the canvas
+ */
constructor(options: {
sakura?: boolean;
}) {
diff --git a/packages/frontend/src/scripts/theme.ts b/packages/frontend/src/scripts/theme.ts
index 5f7e88bd9f..c7f8b3d596 100644
--- a/packages/frontend/src/scripts/theme.ts
+++ b/packages/frontend/src/scripts/theme.ts
@@ -6,7 +6,7 @@
import { ref } from 'vue';
import tinycolor from 'tinycolor2';
import { deepClone } from './clone.js';
-import type { BuiltinTheme } from 'shiki';
+import type { BundledTheme } from 'shiki/themes';
import { globalEvents } from '@/events.js';
import lightTheme from '@/themes/_light.json5';
import darkTheme from '@/themes/_dark.json5';
@@ -20,7 +20,7 @@ export type Theme = {
base?: 'dark' | 'light';
props: Record<string, string>;
codeHighlighter?: {
- base: BuiltinTheme;
+ base: BundledTheme;
overrides?: Record<string, any>;
} | {
base: '_none_';
diff --git a/packages/frontend/src/scripts/upload.ts b/packages/frontend/src/scripts/upload.ts
index 6c46b2bc1b..3e947183c9 100644
--- a/packages/frontend/src/scripts/upload.ts
+++ b/packages/frontend/src/scripts/upload.ts
@@ -5,6 +5,7 @@
import { reactive, ref } from 'vue';
import * as Misskey from 'misskey-js';
+import { v4 as uuid } from 'uuid';
import { readAndCompressImage } from '@misskey-dev/browser-image-resizer';
import { getCompressionConfig } from './upload/compress-config.js';
import { defaultStore } from '@/store.js';
@@ -39,13 +40,16 @@ export function uploadFile(
if (folder && typeof folder === 'object') folder = folder.id;
return new Promise((resolve, reject) => {
- const id = Math.random().toString();
+ const id = uuid();
const reader = new FileReader();
reader.onload = async (): Promise<void> => {
+ const filename = name ?? file.name ?? 'untitled';
+ const extension = filename.split('.').length > 1 ? '.' + filename.split('.').pop() : '';
+
const ctx = reactive<Uploading>({
- id: id,
- name: name ?? file.name ?? 'untitled',
+ id,
+ name: defaultStore.state.keepOriginalFilename ? filename : id + extension,
progressMax: undefined,
progressValue: undefined,
img: window.URL.createObjectURL(file),
diff --git a/packages/frontend/src/scripts/use-chart-tooltip.ts b/packages/frontend/src/scripts/use-chart-tooltip.ts
index 7e4bf5c9c6..bed221a622 100644
--- a/packages/frontend/src/scripts/use-chart-tooltip.ts
+++ b/packages/frontend/src/scripts/use-chart-tooltip.ts
@@ -53,11 +53,11 @@ export function useChartTooltip(opts: { position: 'top' | 'middle' } = { positio
const rect = context.chart.canvas.getBoundingClientRect();
tooltipShowing.value = true;
- tooltipX.value = rect.left + window.pageXOffset + context.tooltip.caretX;
+ tooltipX.value = rect.left + window.scrollX + context.tooltip.caretX;
if (opts.position === 'top') {
- tooltipY.value = rect.top + window.pageYOffset;
+ tooltipY.value = rect.top + window.scrollY;
} else if (opts.position === 'middle') {
- tooltipY.value = rect.top + window.pageYOffset + context.tooltip.caretY;
+ tooltipY.value = rect.top + window.scrollY + context.tooltip.caretY;
}
}
diff --git a/packages/frontend/src/scripts/use-note-capture.ts b/packages/frontend/src/scripts/use-note-capture.ts
index 524ac5d3fe..542d8ab52b 100644
--- a/packages/frontend/src/scripts/use-note-capture.ts
+++ b/packages/frontend/src/scripts/use-note-capture.ts
@@ -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)) {
diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts
index dfc4169a54..e8eb5a1ed7 100644
--- a/packages/frontend/src/store.ts
+++ b/packages/frontend/src/store.ts
@@ -7,7 +7,6 @@ import { markRaw, ref } from 'vue';
import * as Misskey from 'misskey-js';
import { miLocalStorage } from './local-storage.js';
import type { SoundType } from '@/scripts/sound.js';
-import type { BuiltinTheme as ShikiBuiltinTheme } from 'shiki';
import { Storage } from '@/pizzax.js';
import { hemisphere } from '@/scripts/intl-const.js';
@@ -95,7 +94,7 @@ export const defaultStore = markRaw(new Storage('base', {
},
defaultNoteVisibility: {
where: 'account',
- default: 'public',
+ default: 'public' as (typeof Misskey.noteVisibilities)[number],
},
defaultNoteLocalOnly: {
where: 'account',
@@ -151,7 +150,7 @@ export const defaultStore = markRaw(new Storage('base', {
},
visibility: {
where: 'deviceAccount',
- default: 'public' as 'public' | 'home' | 'followers' | 'specified',
+ default: 'public' as (typeof Misskey.noteVisibilities)[number],
},
localOnly: {
where: 'deviceAccount',
@@ -227,6 +226,10 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'device',
default: true,
},
+ showReactionsCount: {
+ where: 'device',
+ default: false,
+ },
enableQuickAddMfmFunction: {
where: 'device',
default: false,
@@ -431,14 +434,26 @@ export const defaultStore = markRaw(new Storage('base', {
sfxVolume: 1,
},
},
- hemisphere: {
+ hemisphere: {
where: 'device',
default: hemisphere as 'N' | 'S',
- },
+ },
enableHorizontalSwipe: {
where: 'device',
default: true,
},
+ useNativeUIForVideoAudioPlayer: {
+ where: 'device',
+ default: false,
+ },
+ keepOriginalFilename: {
+ where: 'device',
+ default: true,
+ },
+ alwaysConfirmFollow: {
+ where: 'device',
+ default: true,
+ },
sound_masterVolume: {
where: 'device',
diff --git a/packages/frontend/src/stream.ts b/packages/frontend/src/stream.ts
index 0c5ee06197..0d5bd78b09 100644
--- a/packages/frontend/src/stream.ts
+++ b/packages/frontend/src/stream.ts
@@ -8,7 +8,12 @@ import { markRaw } from 'vue';
import { $i } from '@/account.js';
import { wsOrigin } from '@/config.js';
+// heart beat interval in ms
+const HEART_BEAT_INTERVAL = 1000 * 60;
+
let stream: Misskey.Stream | null = null;
+let timeoutHeartBeat: ReturnType<typeof setTimeout> | null = null;
+let lastHeartbeatCall = 0;
export function useStream(): Misskey.Stream {
if (stream) return stream;
@@ -17,7 +22,18 @@ export function useStream(): Misskey.Stream {
token: $i.token,
} : null));
- window.setTimeout(heartbeat, 1000 * 60);
+ if (timeoutHeartBeat) window.clearTimeout(timeoutHeartBeat);
+ timeoutHeartBeat = window.setTimeout(heartbeat, HEART_BEAT_INTERVAL);
+
+ // send heartbeat right now when last send time is over HEART_BEAT_INTERVAL
+ document.addEventListener('visibilitychange', () => {
+ if (
+ !stream
+ || document.visibilityState !== 'visible'
+ || Date.now() - lastHeartbeatCall < HEART_BEAT_INTERVAL
+ ) return;
+ heartbeat();
+ });
return stream;
}
@@ -26,5 +42,7 @@ function heartbeat(): void {
if (stream != null && document.visibilityState === 'visible') {
stream.heartbeat();
}
- window.setTimeout(heartbeat, 1000 * 60);
+ lastHeartbeatCall = Date.now();
+ if (timeoutHeartBeat) window.clearTimeout(timeoutHeartBeat);
+ timeoutHeartBeat = window.setTimeout(heartbeat, HEART_BEAT_INTERVAL);
}
diff --git a/packages/frontend/src/style.scss b/packages/frontend/src/style.scss
index 0951a7d98d..250a2616a7 100644
--- a/packages/frontend/src/style.scss
+++ b/packages/frontend/src/style.scss
@@ -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 {
@@ -424,12 +431,13 @@ rt {
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);
+}
+
+html[data-color-mode=dark] ._woodenFrame {
+ --bg: #1d0c02;
+ --fg: #F1E8DC;
+ --panel: #192320;
}
._woodenFrameH {
diff --git a/packages/frontend/src/type.ts b/packages/frontend/src/type.ts
index 9c0fc2a11e..5ff27158d2 100644
--- a/packages/frontend/src/type.ts
+++ b/packages/frontend/src/type.ts
@@ -1,3 +1,8 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
export type WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] };
export type WithNonNullable<T, K extends keyof T> = T & { [P in K]-?: NonNullable<T[P]> };
diff --git a/packages/frontend/src/types/menu.ts b/packages/frontend/src/types/menu.ts
index 712f3464e5..138eb7dd62 100644
--- a/packages/frontend/src/types/menu.ts
+++ b/packages/frontend/src/types/menu.ts
@@ -6,6 +6,8 @@
import * as Misskey from 'misskey-js';
import { ComputedRef, Ref } from 'vue';
+interface MenuRadioOptionsDef extends Record<string, any> { }
+
export type MenuAction = (ev: MouseEvent) => void;
export type MenuDivider = { type: 'divider' };
@@ -14,13 +16,15 @@ export type MenuLabel = { type: 'label', text: string };
export type MenuLink = { type: 'link', to: string, text: string, icon?: string, indicate?: boolean, avatar?: Misskey.entities.User };
export type MenuA = { type: 'a', href: string, target?: string, download?: string, text: string, icon?: string, indicate?: boolean };
export type MenuUser = { type: 'user', user: Misskey.entities.User, active?: boolean, indicate?: boolean, action: MenuAction };
-export type MenuSwitch = { type: 'switch', ref: Ref<boolean>, text: string, disabled?: boolean | Ref<boolean> };
+export type MenuSwitch = { type: 'switch', ref: Ref<boolean>, text: string, icon?: string, disabled?: boolean | Ref<boolean> };
export type MenuButton = { type?: 'button', text: string, icon?: string, indicate?: boolean, danger?: boolean, active?: boolean | ComputedRef<boolean>, avatar?: Misskey.entities.User; action: MenuAction };
+export type MenuRadio = { type: 'radio', text: string, icon?: string, ref: Ref<MenuRadioOptionsDef[keyof MenuRadioOptionsDef]>, options: MenuRadioOptionsDef, disabled?: boolean | Ref<boolean> };
+export type MenuRadioOption = { type: 'radioOption', text: string, action: MenuAction; active?: boolean | ComputedRef<boolean> };
export type MenuParent = { type: 'parent', text: string, icon?: string, children: MenuItem[] | (() => Promise<MenuItem[]> | MenuItem[]) };
export type MenuPending = { type: 'pending' };
-type OuterMenuItem = MenuDivider | MenuNull | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuParent;
+type OuterMenuItem = MenuDivider | MenuNull | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuRadio | MenuRadioOption | MenuParent;
type OuterPromiseMenuItem = Promise<MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuParent>;
export type MenuItem = OuterMenuItem | OuterPromiseMenuItem;
-export type InnerMenuItem = MenuDivider | MenuPending | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuParent;
+export type InnerMenuItem = MenuDivider | MenuPending | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuRadio | MenuRadioOption | MenuParent;
diff --git a/packages/frontend/src/ui/_common_/announcements.vue b/packages/frontend/src/ui/_common_/announcements.vue
index 362c29e6c2..374bc20b54 100644
--- a/packages/frontend/src/ui/_common_/announcements.vue
+++ b/packages/frontend/src/ui/_common_/announcements.vue
@@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
v-for="announcement in $i.unreadAnnouncements.filter(x => x.display === 'banner')"
:key="announcement.id"
:class="$style.item"
- to="/announcements"
+ :to="`/announcements/${announcement.id}`"
>
<span :class="$style.icon">
<i v-if="announcement.icon === 'info'" class="ti ti-info-circle"></i>
diff --git a/packages/frontend/src/ui/_common_/common.ts b/packages/frontend/src/ui/_common_/common.ts
index 9b510a6292..839fa5faf8 100644
--- a/packages/frontend/src/ui/_common_/common.ts
+++ b/packages/frontend/src/ui/_common_/common.ts
@@ -79,7 +79,12 @@ export function openInstanceMenu(ev: MouseEvent) {
text: i18n.ts.tools,
icon: 'ti ti-tool',
children: toolsMenuItems(),
- }, { type: 'divider' }, (instance.impressumUrl) ? {
+ }, { type: 'divider' }, {
+ type: 'link',
+ text: i18n.ts.inquiry,
+ icon: 'ti ti-help-circle',
+ to: '/contact',
+ }, (instance.impressumUrl) ? {
text: i18n.ts.impressum,
icon: 'ti ti-file-invoice',
action: () => {
@@ -98,8 +103,8 @@ export function openInstanceMenu(ev: MouseEvent) {
window.open(instance.privacyPolicyUrl, '_blank', 'noopener');
},
} : undefined, (!instance.impressumUrl && !instance.tosUrl && !instance.privacyPolicyUrl) ? undefined : { type: 'divider' }, {
- text: i18n.ts.help,
- icon: 'ti ti-help-circle',
+ text: i18n.ts.document,
+ icon: 'ti ti-bulb',
action: () => {
window.open('https://misskey-hub.net/docs/for-users/', '_blank', 'noopener');
},
diff --git a/packages/frontend/src/ui/_common_/statusbar-rss.vue b/packages/frontend/src/ui/_common_/statusbar-rss.vue
index b973a4fd6b..6e1d06eec1 100644
--- a/packages/frontend/src/ui/_common_/statusbar-rss.vue
+++ b/packages/frontend/src/ui/_common_/statusbar-rss.vue
@@ -28,6 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { ref } from 'vue';
+import * as Misskey from 'misskey-js';
import MarqueeText from '@/components/MkMarquee.vue';
import { useInterval } from '@/scripts/use-interval.js';
import { shuffle } from '@/scripts/shuffle.js';
@@ -42,13 +43,13 @@ const props = defineProps<{
refreshIntervalSec?: number;
}>();
-const items = ref([]);
+const items = ref<Misskey.entities.FetchRssResponse['items']>([]);
const fetching = ref(true);
const key = ref(0);
const tick = () => {
window.fetch(`/api/fetch-rss?url=${props.url}`, {}).then(res => {
- res.json().then(feed => {
+ res.json().then((feed: Misskey.entities.FetchRssResponse) => {
if (props.shuffle) {
shuffle(feed.items);
}
diff --git a/packages/frontend/src/ui/deck/antenna-column.vue b/packages/frontend/src/ui/deck/antenna-column.vue
index b42a21bf6f..c3dc1e4fce 100644
--- a/packages/frontend/src/ui/deck/antenna-column.vue
+++ b/packages/frontend/src/ui/deck/antenna-column.vue
@@ -9,18 +9,22 @@ SPDX-License-Identifier: AGPL-3.0-only
<i class="ti ti-antenna"></i><span style="margin-left: 8px;">{{ column.name }}</span>
</template>
- <MkTimeline v-if="column.antennaId" ref="timeline" src="antenna" :antenna="column.antennaId"/>
+ <MkTimeline v-if="column.antennaId" ref="timeline" src="antenna" :antenna="column.antennaId" @note="onNote"/>
</XColumn>
</template>
<script lang="ts" setup>
-import { onMounted, shallowRef } from 'vue';
+import { onMounted, ref, shallowRef, watch } from 'vue';
import XColumn from './column.vue';
import { updateColumn, Column } from './deck-store.js';
import MkTimeline from '@/components/MkTimeline.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js';
+import { MenuItem } from '@/types/menu.js';
+import { SoundStore } from '@/store.js';
+import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js';
+import * as sound from '@/scripts/sound.js';
const props = defineProps<{
column: Column;
@@ -28,6 +32,7 @@ const props = defineProps<{
}>();
const timeline = shallowRef<InstanceType<typeof MkTimeline>>();
+const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 });
onMounted(() => {
if (props.column.antennaId == null) {
@@ -35,6 +40,10 @@ onMounted(() => {
}
});
+watch(soundSetting, v => {
+ updateColumn(props.column.id, { soundSetting: v });
+});
+
async function setAntenna() {
const antennas = await misskeyApi('antennas/list');
const { canceled, result: antenna } = await os.select({
@@ -54,7 +63,11 @@ function editAntenna() {
os.pageWindow('my/antennas/' + props.column.antennaId);
}
-const menu = [
+function onNote() {
+ sound.playMisskeySfxFile(soundSetting.value);
+}
+
+const menu: MenuItem[] = [
{
icon: 'ti ti-pencil',
text: i18n.ts.selectAntenna,
@@ -65,6 +78,11 @@ const menu = [
text: i18n.ts.editAntenna,
action: editAntenna,
},
+ {
+ icon: 'ti ti-bell',
+ text: i18n.ts._deck.newNoteNotificationSettings,
+ action: () => soundSettingsButton(soundSetting),
+ },
];
/*
diff --git a/packages/frontend/src/ui/deck/channel-column.vue b/packages/frontend/src/ui/deck/channel-column.vue
index bd3b059497..7c5b13eaf1 100644
--- a/packages/frontend/src/ui/deck/channel-column.vue
+++ b/packages/frontend/src/ui/deck/channel-column.vue
@@ -13,21 +13,26 @@ SPDX-License-Identifier: AGPL-3.0-only
<div style="padding: 8px; text-align: center;">
<MkButton primary gradate rounded inline small @click="post"><i class="ti ti-pencil"></i></MkButton>
</div>
- <MkTimeline ref="timeline" src="channel" :channel="column.channelId"/>
+ <MkTimeline ref="timeline" src="channel" :channel="column.channelId" @note="onNote"/>
</template>
</XColumn>
</template>
<script lang="ts" setup>
-import { shallowRef } from 'vue';
+import { ref, shallowRef, watch } from 'vue';
import * as Misskey from 'misskey-js';
import XColumn from './column.vue';
import { updateColumn, Column } from './deck-store.js';
import MkTimeline from '@/components/MkTimeline.vue';
import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js';
+import { favoritedChannelsCache } from '@/cache.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js';
+import { MenuItem } from '@/types/menu.js';
+import { SoundStore } from '@/store.js';
+import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js';
+import * as sound from '@/scripts/sound.js';
const props = defineProps<{
column: Column;
@@ -36,26 +41,29 @@ const props = defineProps<{
const timeline = shallowRef<InstanceType<typeof MkTimeline>>();
const channel = shallowRef<Misskey.entities.Channel>();
+const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 });
if (props.column.channelId == null) {
setChannel();
}
+watch(soundSetting, v => {
+ updateColumn(props.column.id, { soundSetting: v });
+});
+
async function setChannel() {
- const channels = await misskeyApi('channels/my-favorites', {
- limit: 100,
- });
- const { canceled, result: channel } = await os.select({
+ const channels = await favoritedChannelsCache.fetch();
+ const { canceled, result: chosenChannel } = await os.select({
title: i18n.ts.selectChannel,
items: channels.map(x => ({
value: x, text: x.name,
})),
default: props.column.channelId,
});
- if (canceled) return;
+ if (canceled || chosenChannel == null) return;
updateColumn(props.column.id, {
- channelId: channel.id,
- name: channel.name,
+ channelId: chosenChannel.id,
+ name: chosenChannel.name,
});
}
@@ -71,9 +79,17 @@ async function post() {
});
}
-const menu = [{
+function onNote() {
+ sound.playMisskeySfxFile(soundSetting.value);
+}
+
+const menu: MenuItem[] = [{
icon: 'ti ti-pencil',
text: i18n.ts.selectChannel,
action: setChannel,
+}, {
+ icon: 'ti ti-bell',
+ text: i18n.ts._deck.newNoteNotificationSettings,
+ action: () => soundSettingsButton(soundSetting),
}];
</script>
diff --git a/packages/frontend/src/ui/deck/deck-store.ts b/packages/frontend/src/ui/deck/deck-store.ts
index 70b55e8172..bb3c04cd5c 100644
--- a/packages/frontend/src/ui/deck/deck-store.ts
+++ b/packages/frontend/src/ui/deck/deck-store.ts
@@ -9,6 +9,7 @@ import { notificationTypes } from 'misskey-js';
import { Storage } from '@/pizzax.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { deepClone } from '@/scripts/clone.js';
+import { SoundStore } from '@/store.js';
type ColumnWidget = {
name: string;
@@ -33,6 +34,7 @@ export type Column = {
withRenotes?: boolean;
withReplies?: boolean;
onlyFiles?: boolean;
+ soundSetting: SoundStore;
};
export const deckStore = markRaw(new Storage('deck', {
diff --git a/packages/frontend/src/ui/deck/list-column.vue b/packages/frontend/src/ui/deck/list-column.vue
index 70ea54326f..5369112494 100644
--- a/packages/frontend/src/ui/deck/list-column.vue
+++ b/packages/frontend/src/ui/deck/list-column.vue
@@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<i class="ti ti-list"></i><span style="margin-left: 8px;">{{ column.name }}</span>
</template>
- <MkTimeline v-if="column.listId" ref="timeline" src="list" :list="column.listId" :withRenotes="withRenotes"/>
+ <MkTimeline v-if="column.listId" ref="timeline" src="list" :list="column.listId" :withRenotes="withRenotes" @note="onNote"/>
</XColumn>
</template>
@@ -21,6 +21,10 @@ import MkTimeline from '@/components/MkTimeline.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js';
+import { MenuItem } from '@/types/menu.js';
+import { SoundStore } from '@/store.js';
+import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js';
+import * as sound from '@/scripts/sound.js';
const props = defineProps<{
column: Column;
@@ -29,6 +33,7 @@ const props = defineProps<{
const timeline = shallowRef<InstanceType<typeof MkTimeline>>();
const withRenotes = ref(props.column.withRenotes ?? true);
+const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 });
if (props.column.listId == null) {
setList();
@@ -40,6 +45,10 @@ watch(withRenotes, v => {
});
});
+watch(soundSetting, v => {
+ updateColumn(props.column.id, { soundSetting: v });
+});
+
async function setList() {
const lists = await misskeyApi('users/lists/list');
const { canceled, result: list } = await os.select({
@@ -59,7 +68,11 @@ function editList() {
os.pageWindow('my/lists/' + props.column.listId);
}
-const menu = [
+function onNote() {
+ sound.playMisskeySfxFile(soundSetting.value);
+}
+
+const menu: MenuItem[] = [
{
icon: 'ti ti-pencil',
text: i18n.ts.selectList,
@@ -75,5 +88,10 @@ const menu = [
text: i18n.ts.showRenotes,
ref: withRenotes,
},
+ {
+ icon: 'ti ti-bell',
+ text: i18n.ts._deck.newNoteNotificationSettings,
+ action: () => soundSettingsButton(soundSetting),
+ },
];
</script>
diff --git a/packages/frontend/src/ui/deck/role-timeline-column.vue b/packages/frontend/src/ui/deck/role-timeline-column.vue
index eae2ee13f3..32ab7527b4 100644
--- a/packages/frontend/src/ui/deck/role-timeline-column.vue
+++ b/packages/frontend/src/ui/deck/role-timeline-column.vue
@@ -9,18 +9,22 @@ SPDX-License-Identifier: AGPL-3.0-only
<i class="ti ti-badge"></i><span style="margin-left: 8px;">{{ column.name }}</span>
</template>
- <MkTimeline v-if="column.roleId" ref="timeline" src="role" :role="column.roleId"/>
+ <MkTimeline v-if="column.roleId" ref="timeline" src="role" :role="column.roleId" @note="onNote"/>
</XColumn>
</template>
<script lang="ts" setup>
-import { onMounted, shallowRef } from 'vue';
+import { onMounted, ref, shallowRef, watch } from 'vue';
import XColumn from './column.vue';
import { updateColumn, Column } from './deck-store.js';
import MkTimeline from '@/components/MkTimeline.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js';
+import { MenuItem } from '@/types/menu.js';
+import { SoundStore } from '@/store.js';
+import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js';
+import * as sound from '@/scripts/sound.js';
const props = defineProps<{
column: Column;
@@ -28,6 +32,7 @@ const props = defineProps<{
}>();
const timeline = shallowRef<InstanceType<typeof MkTimeline>>();
+const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 });
onMounted(() => {
if (props.column.roleId == null) {
@@ -35,6 +40,10 @@ onMounted(() => {
}
});
+watch(soundSetting, v => {
+ updateColumn(props.column.id, { soundSetting: v });
+});
+
async function setRole() {
const roles = (await misskeyApi('roles/list')).filter(x => x.isExplorable);
const { canceled, result: role } = await os.select({
@@ -50,10 +59,18 @@ async function setRole() {
});
}
-const menu = [{
+function onNote() {
+ sound.playMisskeySfxFile(soundSetting.value);
+}
+
+const menu: MenuItem[] = [{
icon: 'ti ti-pencil',
text: i18n.ts.role,
action: setRole,
+}, {
+ icon: 'ti ti-bell',
+ text: i18n.ts._deck.newNoteNotificationSettings,
+ action: () => soundSettingsButton(soundSetting),
}];
/*
diff --git a/packages/frontend/src/ui/deck/tl-column.vue b/packages/frontend/src/ui/deck/tl-column.vue
index f9066d9db7..a967335edf 100644
--- a/packages/frontend/src/ui/deck/tl-column.vue
+++ b/packages/frontend/src/ui/deck/tl-column.vue
@@ -28,6 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:withRenotes="withRenotes"
:withReplies="withReplies"
:onlyFiles="onlyFiles"
+ @note="onNote"
/>
</XColumn>
</template>
@@ -41,6 +42,10 @@ import * as os from '@/os.js';
import { $i } from '@/account.js';
import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js';
+import { MenuItem } from '@/types/menu.js';
+import { SoundStore } from '@/store.js';
+import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js';
+import * as sound from '@/scripts/sound.js';
const props = defineProps<{
column: Column;
@@ -52,6 +57,7 @@ const timeline = shallowRef<InstanceType<typeof MkTimeline>>();
const isLocalTimelineAvailable = (($i == null && instance.policies.ltlAvailable) || ($i != null && $i.policies.ltlAvailable));
const isGlobalTimelineAvailable = (($i == null && instance.policies.gtlAvailable) || ($i != null && $i.policies.gtlAvailable));
+const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 });
const withRenotes = ref(props.column.withRenotes ?? true);
const withReplies = ref(props.column.withReplies ?? false);
const onlyFiles = ref(props.column.onlyFiles ?? false);
@@ -74,6 +80,10 @@ watch(onlyFiles, v => {
});
});
+watch(soundSetting, v => {
+ updateColumn(props.column.id, { soundSetting: v });
+});
+
onMounted(() => {
if (props.column.tl == null) {
setType();
@@ -108,11 +118,19 @@ async function setType() {
});
}
-const menu = [{
+function onNote() {
+ sound.playMisskeySfxFile(soundSetting.value);
+}
+
+const menu: MenuItem[] = [{
icon: 'ti ti-pencil',
text: i18n.ts.timeline,
action: setType,
}, {
+ icon: 'ti ti-bell',
+ text: i18n.ts._deck.newNoteNotificationSettings,
+ action: () => soundSettingsButton(soundSetting),
+}, {
type: 'switch',
text: i18n.ts.showRenotes,
ref: withRenotes,
diff --git a/packages/frontend/src/ui/deck/tl-note-notification.ts b/packages/frontend/src/ui/deck/tl-note-notification.ts
new file mode 100644
index 0000000000..275ea56ba0
--- /dev/null
+++ b/packages/frontend/src/ui/deck/tl-note-notification.ts
@@ -0,0 +1,107 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import * as Misskey from 'misskey-js';
+import { Ref } from 'vue';
+import { SoundStore } from '@/store.js';
+import { getSoundDuration, playMisskeySfxFile, soundsTypes, SoundType } from '@/scripts/sound.js';
+import { i18n } from '@/i18n.js';
+import * as os from '@/os.js';
+
+export async function soundSettingsButton(soundSetting: Ref<SoundStore>): Promise<void> {
+ function getSoundTypeName(f: SoundType): string {
+ switch (f) {
+ case null:
+ return i18n.ts.none;
+ case '_driveFile_':
+ return i18n.ts._soundSettings.driveFile;
+ default:
+ return f;
+ }
+ }
+
+ const { canceled, result } = await os.form(i18n.ts.sound, {
+ type: {
+ type: 'enum',
+ label: i18n.ts.sound,
+ default: soundSetting.value.type ?? 'none',
+ enum: soundsTypes.map(f => ({
+ value: f ?? 'none', label: getSoundTypeName(f),
+ })),
+ },
+ soundFile: {
+ type: 'drive-file',
+ label: i18n.ts.file,
+ defaultFileId: soundSetting.value.type === '_driveFile_' ? soundSetting.value.fileId : null,
+ hidden: v => v.type !== '_driveFile_',
+ validate: async (file: Misskey.entities.DriveFile) => {
+ if (!file.type.startsWith('audio')) {
+ os.alert({
+ type: 'warning',
+ title: i18n.ts._soundSettings.driveFileTypeWarn,
+ text: i18n.ts._soundSettings.driveFileTypeWarnDescription,
+ });
+ return false;
+ }
+
+ const duration = await getSoundDuration(file.url);
+ if (duration >= 2000) {
+ const { canceled } = await os.confirm({
+ type: 'warning',
+ title: i18n.ts._soundSettings.driveFileDurationWarn,
+ text: i18n.ts._soundSettings.driveFileDurationWarnDescription,
+ okText: i18n.ts.continue,
+ cancelText: i18n.ts.cancel,
+ });
+ if (canceled) return false;
+ }
+
+ return true;
+ },
+ },
+ volume: {
+ type: 'range',
+ label: i18n.ts.volume,
+ default: soundSetting.value.volume ?? 1,
+ textConverter: (v) => `${Math.floor(v * 100)}%`,
+ min: 0,
+ max: 1,
+ step: 0.05,
+ },
+ listen: {
+ type: 'button',
+ content: i18n.ts.listen,
+ action: (_, v) => {
+ const sound = buildSoundStore(v);
+ if (!sound) return;
+ playMisskeySfxFile(sound);
+ },
+ },
+ });
+ if (canceled) return;
+
+ const res = buildSoundStore(result);
+ if (res) soundSetting.value = res;
+
+ function buildSoundStore(result: any): SoundStore | null {
+ const type = (result.type === 'none' ? null : result.type) as SoundType;
+ const volume = result.volume as number;
+ const fileId = result.soundFile?.id ?? (soundSetting.value.type === '_driveFile_' ? soundSetting.value.fileId : undefined);
+ const fileUrl = result.soundFile?.url ?? (soundSetting.value.type === '_driveFile_' ? soundSetting.value.fileUrl : undefined);
+
+ if (type === '_driveFile_') {
+ if (!fileUrl || !fileId) {
+ os.alert({
+ type: 'warning',
+ text: i18n.ts._soundSettings.driveFileWarn,
+ });
+ return null;
+ }
+ return { type, volume, fileId, fileUrl };
+ } else {
+ return { type, volume };
+ }
+ }
+}
diff --git a/packages/frontend/src/ui/visitor.vue b/packages/frontend/src/ui/visitor.vue
index 29b305d9bc..80623083cf 100644
--- a/packages/frontend/src/ui/visitor.vue
+++ b/packages/frontend/src/ui/visitor.vue
@@ -70,11 +70,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { onMounted, provide, ref, computed } from 'vue';
-import * as Misskey from 'misskey-js';
import XCommon from './_common_/common.vue';
import { instanceName } from '@/config.js';
import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
import { instance } from '@/instance.js';
import XSigninDialog from '@/components/MkSigninDialog.vue';
import XSignupDialog from '@/components/MkSignupDialog.vue';
@@ -114,7 +112,6 @@ const isTimelineAvailable = ref(instance.policies?.ltlAvailable || instance.poli
const showMenu = ref(false);
const isDesktop = ref(window.innerWidth >= DESKTOP_THRESHOLD);
const narrow = ref(window.innerWidth < 1280);
-const meta = ref<Misskey.entities.MetaResponse>();
const keymap = computed(() => {
return {
@@ -128,10 +125,6 @@ const keymap = computed(() => {
};
});
-misskeyApi('meta', { detail: true }).then(res => {
- meta.value = res;
-});
-
function signin() {
os.popup(XSigninDialog, {
autoSet: true,
diff --git a/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue b/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue
index 5b448e2c3b..49fd103d37 100644
--- a/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue
+++ b/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue
@@ -7,6 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkContainer :showHeader="widgetProps.showHeader" class="mkw-bdayfollowings">
<template #icon><i class="ti ti-cake"></i></template>
<template #header>{{ i18n.ts._widgets.birthdayFollowings }}</template>
+ <template #func="{ buttonStyleClass }"><button class="_button" :class="buttonStyleClass" @click="actualFetch()"><i class="ti ti-refresh"></i></button></template>
<div :class="$style.bdayFRoot">
<MkLoading v-if="fetching"/>
@@ -53,7 +54,7 @@ const { widgetProps, configure } = useWidgetPropsManager(name,
emit,
);
-const users = ref<Misskey.entities.FollowingFolloweePopulated[]>([]);
+const users = ref<Misskey.Endpoints['users/following']['res']>([]);
const fetching = ref(true);
let lastFetchedAt = '1970-01-01';
@@ -70,19 +71,35 @@ const fetch = () => {
now.setHours(0, 0, 0, 0);
if (now > lfAtD) {
- misskeyApi('users/following', {
- limit: 18,
- birthday: now.toISOString(),
- userId: $i.id,
- }).then(res => {
- users.value = res;
- fetching.value = false;
- });
+ actualFetch();
lastFetchedAt = now.toISOString();
}
};
+function actualFetch() {
+ if ($i == null) {
+ users.value = [];
+ fetching.value = false;
+ return;
+ }
+
+ const now = new Date();
+ now.setHours(0, 0, 0, 0);
+ fetching.value = true;
+ misskeyApi('users/following', {
+ limit: 18,
+ birthday: `${now.getFullYear().toString().padStart(4, '0')}-${(now.getMonth() + 1).toString().padStart(2, '0')}-${now.getDate().toString().padStart(2, '0')}`,
+ userId: $i.id,
+ }).then(res => {
+ users.value = res;
+ window.setTimeout(() => {
+ // 早すぎるとチカチカする
+ fetching.value = false;
+ }, 100);
+ });
+}
+
useInterval(fetch, 1000 * 60, {
immediate: true,
afterMounted: true,
diff --git a/packages/frontend/src/widgets/WidgetRss.vue b/packages/frontend/src/widgets/WidgetRss.vue
index 5d5c1188aa..e5758662cc 100644
--- a/packages/frontend/src/widgets/WidgetRss.vue
+++ b/packages/frontend/src/widgets/WidgetRss.vue
@@ -24,6 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { ref, watch, computed } from 'vue';
+import * as Misskey from 'misskey-js';
import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
import { GetFormResultType } from '@/scripts/form.js';
import MkContainer from '@/components/MkContainer.vue';
@@ -64,7 +65,7 @@ const { widgetProps, configure } = useWidgetPropsManager(name,
emit,
);
-const rawItems = ref([]);
+const rawItems = ref<Misskey.entities.FetchRssResponse['items']>([]);
const items = computed(() => rawItems.value.slice(0, widgetProps.maxEntries));
const fetching = ref(true);
const fetchEndpoint = computed(() => {
@@ -79,8 +80,8 @@ const tick = () => {
window.fetch(fetchEndpoint.value, {})
.then(res => res.json())
- .then(feed => {
- rawItems.value = feed.items ?? [];
+ .then((feed: Misskey.entities.FetchRssResponse) => {
+ rawItems.value = feed.items;
fetching.value = false;
});
};
diff --git a/packages/frontend/src/widgets/WidgetRssTicker.vue b/packages/frontend/src/widgets/WidgetRssTicker.vue
index af220f95e2..16306ef5ba 100644
--- a/packages/frontend/src/widgets/WidgetRssTicker.vue
+++ b/packages/frontend/src/widgets/WidgetRssTicker.vue
@@ -28,6 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { ref, watch, computed } from 'vue';
+import * as Misskey from 'misskey-js';
import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
import MarqueeText from '@/components/MkMarquee.vue';
import { GetFormResultType } from '@/scripts/form.js';
@@ -87,7 +88,7 @@ const { widgetProps, configure } = useWidgetPropsManager(name,
emit,
);
-const rawItems = ref([]);
+const rawItems = ref<Misskey.entities.FetchRssResponse['items']>([]);
const items = computed(() => {
const newItems = rawItems.value.slice(0, widgetProps.maxEntries);
if (widgetProps.shuffle) {
@@ -110,8 +111,8 @@ const tick = () => {
window.fetch(fetchEndpoint.value, {})
.then(res => res.json())
- .then(feed => {
- rawItems.value = feed.items ?? [];
+ .then((feed: Misskey.entities.FetchRssResponse) => {
+ rawItems.value = feed.items;
fetching.value = false;
key.value++;
});
diff --git a/packages/frontend/src/widgets/WidgetUnixClock.vue b/packages/frontend/src/widgets/WidgetUnixClock.vue
index 2ac7d1c781..832cd575cc 100644
--- a/packages/frontend/src/widgets/WidgetUnixClock.vue
+++ b/packages/frontend/src/widgets/WidgetUnixClock.vue
@@ -68,9 +68,9 @@ watch(showColon, (v) => {
});
const tick = () => {
- const now = new Date();
- ss.value = Math.floor(now.getTime() / 1000).toString();
- ms.value = Math.floor(now.getTime() % 1000 / 10).toString().padStart(2, '0');
+ const now = Date.now();
+ ss.value = Math.floor(now / 1000).toString();
+ ms.value = Math.floor(now % 1000 / 10).toString().padStart(2, '0');
if (ss.value !== prevSec) showColon.value = true;
prevSec = ss.value;
};