summaryrefslogtreecommitdiff
path: root/packages/frontend/src/components
diff options
context:
space:
mode:
authordakkar <dakkar@thenautilus.net>2024-06-26 19:57:45 +0000
committerdakkar <dakkar@thenautilus.net>2024-06-26 19:57:45 +0000
commitdc4f6c8016ccece79f8f2b75a5378c643e884f0b (patch)
tree3e2c9e998a2f97244a37f3f8cb7f1831e4108ab7 /packages/frontend/src/components
parentmerge: Release 2024.3.3 (!501) (diff)
parentmerge: parse `notRespondingSince` from redis instance cache (!560) (diff)
downloadsharkey-dc4f6c8016ccece79f8f2b75a5378c643e884f0b.tar.gz
sharkey-dc4f6c8016ccece79f8f2b75a5378c643e884f0b.tar.bz2
sharkey-dc4f6c8016ccece79f8f2b75a5378c643e884f0b.zip
merge: release 2024.5.0 (!556)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/556 Approved-by: Tess K <me@thvxl.se>
Diffstat (limited to 'packages/frontend/src/components')
-rw-r--r--packages/frontend/src/components/CkFollowMouse.vue86
-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/MkAnnouncementDialog.vue2
-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.vue4
-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/MkGoogle.vue8
-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.vue143
-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/MkMfmWindow.vue163
-rw-r--r--packages/frontend/src/components/MkModPlayer.vue5
-rw-r--r--packages/frontend/src/components/MkModal.vue29
-rw-r--r--packages/frontend/src/components/MkNote.vue114
-rw-r--r--packages/frontend/src/components/MkNoteDetailed.vue91
-rw-r--r--packages/frontend/src/components/MkNotePreview.vue4
-rw-r--r--packages/frontend/src/components/MkNoteSimple.vue2
-rw-r--r--packages/frontend/src/components/MkNoteSub.vue2
-rw-r--r--packages/frontend/src/components/MkNotification.vue50
-rw-r--r--packages/frontend/src/components/MkPagination.vue8
-rw-r--r--packages/frontend/src/components/MkPasswordDialog.vue26
-rw-r--r--packages/frontend/src/components/MkPostForm.vue47
-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/MkSubNoteContent.vue4
-rw-r--r--packages/frontend/src/components/MkSwitch.button.vue13
-rw-r--r--packages/frontend/src/components/MkTextarea.vue2
-rw-r--r--packages/frontend/src/components/MkTimeline.vue4
-rw-r--r--packages/frontend/src/components/MkToast.vue10
-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/MkUserInfo.vue2
-rw-r--r--packages/frontend/src/components/MkUserPopup.vue6
-rw-r--r--packages/frontend/src/components/MkUserSetupDialog.User.vue2
-rw-r--r--packages/frontend/src/components/MkVisitorDashboard.vue56
-rw-r--r--packages/frontend/src/components/SkApprovalUser.vue5
-rw-r--r--packages/frontend/src/components/SkNote.vue133
-rw-r--r--packages/frontend/src/components/SkNoteDetailed.vue91
-rw-r--r--packages/frontend/src/components/SkNoteHeader.vue2
-rw-r--r--packages/frontend/src/components/SkNoteSimple.vue2
-rw-r--r--packages/frontend/src/components/SkNoteSub.vue4
-rw-r--r--packages/frontend/src/components/SkOldNoteWindow.vue11
-rw-r--r--packages/frontend/src/components/SkOneko.vue7
-rw-r--r--packages/frontend/src/components/SkSearchResultWindow.vue5
-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.ts110
-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.vue15
-rw-r--r--packages/frontend/src/components/page/page.vue2
84 files changed, 1613 insertions, 505 deletions
diff --git a/packages/frontend/src/components/CkFollowMouse.vue b/packages/frontend/src/components/CkFollowMouse.vue
new file mode 100644
index 0000000000..ce7e3c79a8
--- /dev/null
+++ b/packages/frontend/src/components/CkFollowMouse.vue
@@ -0,0 +1,86 @@
+<!--
+SPDX-FileCopyrightText: leah and other Cutiekey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<span ref="container" :class="$style.root">
+ <span ref="el" :class="$style.inner" style="position: absolute">
+ <slot></slot>
+ </span>
+</span>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, shallowRef } from 'vue';
+const el = shallowRef<HTMLElement>();
+const container = shallowRef<HTMLElement>();
+const props = defineProps({
+ x: {
+ type: Boolean,
+ default: true,
+ },
+ y: {
+ type: Boolean,
+ default: true,
+ },
+ speed: {
+ type: String,
+ default: '0.1s',
+ },
+ rotateByVelocity: {
+ type: Boolean,
+ default: true,
+ },
+});
+
+let lastX = 0;
+let lastY = 0;
+let oldAngle = 0;
+
+function lerp(a, b, alpha) {
+ return a + alpha * (b - a);
+}
+
+const updatePosition = (mouseEvent: MouseEvent) => {
+ if (el.value && container.value) {
+ const containerRect = container.value.getBoundingClientRect();
+ const newX = mouseEvent.clientX - containerRect.left;
+ const newY = mouseEvent.clientY - containerRect.top;
+ let transform = `translate(calc(${props.x ? newX : 0}px - 50%), calc(${props.y ? newY : 0}px - 50%))`;
+ if (props.rotateByVelocity) {
+ const deltaX = newX - lastX;
+ const deltaY = newY - lastY;
+ const angle = lerp(
+ oldAngle,
+ Math.atan2(deltaY, deltaX) * (180 / Math.PI),
+ 0.1,
+ );
+ transform += ` rotate(${angle}deg)`;
+ oldAngle = angle;
+ }
+ el.value.style.transform = transform;
+ el.value.style.transition = `transform ${props.speed}`;
+ lastX = newX;
+ lastY = newY;
+ }
+};
+
+onMounted(() => {
+ window.addEventListener('mousemove', updatePosition);
+});
+
+onUnmounted(() => {
+ window.removeEventListener('mousemove', updatePosition);
+});
+</script>
+
+<style lang="scss" module>
+.root {
+ position: relative;
+ display: inline-block;
+}
+.inner {
+ transform-origin: center center;
+}
+</style>
diff --git a/packages/frontend/src/components/MkAbuseReport.vue b/packages/frontend/src/components/MkAbuseReport.vue
index 0493e885b9..7c8c7dbd30 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" :isBlock="true" :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/MkAnnouncementDialog.vue b/packages/frontend/src/components/MkAnnouncementDialog.vue
index 74d0e7214f..032a815ee6 100644
--- a/packages/frontend/src/components/MkAnnouncementDialog.vue
+++ b/packages/frontend/src/components/MkAnnouncementDialog.vue
@@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</span>
<span :class="$style.title">{{ announcement.title }}</span>
</div>
- <div :class="$style.text"><Mfm :text="announcement.text"/></div>
+ <div :class="$style.text"><Mfm :text="announcement.text" :isBlock="true" /></div>
<MkButton primary full @click="ok">{{ i18n.ts.ok }}</MkButton>
</div>
</MkModal>
diff --git a/packages/frontend/src/components/MkAsUi.vue b/packages/frontend/src/components/MkAsUi.vue
index 11f454daa2..7e150f7dd5 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 c0f41b64d0..c8d2797e16 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 a23b4dc3b2..9e54420034 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 acd2ea6f97..e5589813e1 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 b81ebbbb11..b534ae4c56 100644
--- a/packages/frontend/src/components/MkDialog.vue
+++ b/packages/frontend/src/components/MkDialog.vue
@@ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkLoading v-else-if="type === 'waiting'" :class="$style.iconInner" :em="true"/>
</div>
<header v-if="title" :class="$style.title"><Mfm :text="title"/></header>
- <div v-if="text" :class="$style.text"><Mfm :text="text"/></div>
+ <div v-if="text" :class="$style.text"><Mfm :text="text" :isBlock="true" /></div>
<MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder || undefined" :autocomplete="input.autocomplete" @keydown="onInputKeydown">
<template v-if="input.type === 'password'" #prefix><i class="ph-lock ph-bold ph-lg"></i></template>
<template #caption>
@@ -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 d0e8750e6a..c0a625c69f 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/MkGoogle.vue b/packages/frontend/src/components/MkGoogle.vue
index c92a49d32a..d1809d1073 100644
--- a/packages/frontend/src/components/MkGoogle.vue
+++ b/packages/frontend/src/components/MkGoogle.vue
@@ -13,6 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { ref } from 'vue';
import { i18n } from '@/i18n.js';
+import { defaultStore } from '@/store';
const props = defineProps<{
q: string;
@@ -21,9 +22,10 @@ const props = defineProps<{
const query = ref(props.q);
const search = () => {
- const sp = new URLSearchParams();
- sp.append('q', query.value);
- window.open(`https://www.google.com/search?${sp.toString()}`, '_blank', 'noopener');
+ const searchQuery = encodeURIComponent(query.value);
+ const searchUrl = defaultStore.state.searchEngine.replace(/{query}|%s\b/g, searchQuery);
+
+ window.open(searchUrl, '_blank', 'noopener');
};
</script>
diff --git a/packages/frontend/src/components/MkInput.vue b/packages/frontend/src/components/MkInput.vue
index b026903b66..201b409a05 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 95de0d0247..49cbacd1e8 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"
@click.stop
>
@@ -19,10 +20,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;
}>(), {
});
@@ -30,15 +34,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 6351f5cfbe..41425facc3 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"
@@ -69,12 +86,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
@@ -89,6 +141,30 @@ function showMenu(ev: MouseEvent) {
menu = [
// TODO: 再生キューに追加
{
+ type: 'switch',
+ text: i18n.ts._mediaControls.loop,
+ icon: 'ph ph-repeat',
+ ref: loop,
+ },
+ {
+ type: 'radio',
+ text: i18n.ts._mediaControls.playbackRate,
+ icon: 'ph ph-gauge',
+ 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: 'ph-eye-closed ph-bold ph-lg',
action: () => {
@@ -99,8 +175,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 ? 'ph-eye ph-bold ph-lg' : 'ph-eye-slash ph-bold ph-lg',
danger: true,
@@ -108,6 +182,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: 'ph ph-info',
+ to: `/my/drive/file/${props.audio.id}`,
+ });
+ }
+
menuShowing.value = true;
os.popupMenu(menu, ev.currentTarget ?? ev.target, {
align: 'right',
@@ -141,6 +226,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;
@@ -170,6 +257,7 @@ function toggleMute() {
}
let onceInit = false;
+let mediaTickFrameId: number | null = null;
let stopAudioElWatch: () => void;
function init() {
@@ -189,8 +277,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();
@@ -228,6 +320,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();
});
@@ -246,6 +346,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>
@@ -256,6 +360,10 @@ onDeactivated(() => {
border: .5px solid var(--divider);
border-radius: var(--radius);
overflow: clip;
+
+ &:focus {
+ outline: none;
+ }
}
.sensitive {
@@ -361,4 +469,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 3f9cff8b71..f1fb4f5b44 100644
--- a/packages/frontend/src/components/MkMediaImage.vue
+++ b/packages/frontend/src/components/MkMediaImage.vue
@@ -60,7 +60,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;
@@ -115,6 +115,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: 'ph ph-info',
+ 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 7c14ade130..dbf76bc33d 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,6 +16,7 @@ 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">
@@ -22,7 +25,28 @@ SPDX-License-Identifier: AGPL-3.0-only
<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="ph-eye-closed ph-bold ph-lg" :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="ph-warning ph-bold ph-lg"></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>
@@ -97,12 +123,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'));
@@ -115,6 +175,35 @@ function showMenu(ev: MouseEvent) {
menu = [
// TODO: 再生キューに追加
{
+ type: 'switch',
+ text: i18n.ts._mediaControls.loop,
+ icon: 'ph ph-repeat',
+ ref: loop,
+ },
+ {
+ type: 'radio',
+ text: i18n.ts._mediaControls.playbackRate,
+ icon: 'ph ph-gauge',
+ 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: 'ph ph-picture-in-picture',
+ action: togglePictureInPicture,
+ }] : []),
+ {
+ type: 'divider',
+ },
+ {
text: i18n.ts.hide,
icon: 'ph-eye-closed ph-bold ph-lg',
action: () => {
@@ -125,8 +214,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 ? 'ph-eye ph-bold ph-lg' : 'ph-eye-slash ph-bold ph-lg',
danger: true,
@@ -134,6 +221,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: 'ph ph-info',
+ to: `/my/drive/file/${props.video.id}`,
+ });
+ }
+
menuShowing.value = true;
os.popupMenu(menu, ev.currentTarget ?? ev.target, {
align: 'right',
@@ -180,6 +278,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;
@@ -237,6 +337,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;
@@ -246,6 +356,7 @@ function toggleMute() {
}
let onceInit = false;
+let mediaTickFrameId: number | null = null;
let stopVideoElWatch: () => void;
function init() {
@@ -265,8 +376,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();
@@ -310,6 +425,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();
@@ -335,6 +458,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>
@@ -343,6 +470,10 @@ onDeactivated(() => {
container-type: inline-size;
position: relative;
overflow: clip;
+
+ &:focus {
+ outline: none;
+ }
}
.sensitive {
@@ -406,7 +537,7 @@ onDeactivated(() => {
font: inherit;
color: inherit;
cursor: pointer;
- padding: 120px 0;
+ padding: 60px 0;
display: flex;
align-items: center;
justify-content: center;
diff --git a/packages/frontend/src/components/MkMention.vue b/packages/frontend/src/components/MkMention.vue
index 942c23a145..80a1b68459 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 8395879d02..6ced2fecc2 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.switchText]">{{ item.text }}</span>
+ <span :class="$style.item_content_text" style="pointer-events: none;">{{ item.text }}</span>
+ <span :class="$style.caret" style="pointer-events: none;"><i class="ph-caret-right ph-bold ph-lg"></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">{{ 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/MkMfmWindow.vue b/packages/frontend/src/components/MkMfmWindow.vue
index ce2a0e7391..c599531ec5 100644
--- a/packages/frontend/src/components/MkMfmWindow.vue
+++ b/packages/frontend/src/components/MkMfmWindow.vue
@@ -1,3 +1,8 @@
+<!--
+SPDX-FileCopyrightText: marie and other Sharkey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
<template>
<MkWindow
ref="window"
@@ -9,17 +14,17 @@
<template #header>
MFM Cheatsheet
</template>
- <MkStickyContainer>
+ <MkStickyContainer>
<MkSpacer :contentMax="800">
<div class="mfm-cheat-sheet">
<div>{{ i18n.ts._mfm.intro }}</div>
- <br />
+ <br/>
<div class="section _block">
<div class="title">{{ i18n.ts._mfm.mention }}</div>
<div class="content">
<p>{{ i18n.ts._mfm.mentionDescription }}</p>
<div class="preview">
- <Mfm :text="preview_mention" />
+ <Mfm :text="preview_mention"/>
<MkTextarea v-model="preview_mention"><template #label>MFM</template></MkTextarea>
</div>
</div>
@@ -29,7 +34,7 @@
<div class="content">
<p>{{ i18n.ts._mfm.hashtagDescription }}</p>
<div class="preview">
- <Mfm :text="preview_hashtag" />
+ <Mfm :text="preview_hashtag"/>
<MkTextarea v-model="preview_hashtag"><template #label>MFM</template></MkTextarea>
</div>
</div>
@@ -39,7 +44,7 @@
<div class="content">
<p>{{ i18n.ts._mfm.linkDescription }}</p>
<div class="preview">
- <Mfm :text="preview_link" />
+ <Mfm :text="preview_link"/>
<MkTextarea v-model="preview_link"><template #label>MFM</template></MkTextarea>
</div>
</div>
@@ -49,7 +54,7 @@
<div class="content">
<p>{{ i18n.ts._mfm.emojiDescription }}</p>
<div class="preview">
- <Mfm :text="preview_emoji" />
+ <Mfm :text="preview_emoji"/>
<MkTextarea v-model="preview_emoji"><template #label>MFM</template></MkTextarea>
</div>
</div>
@@ -59,7 +64,7 @@
<div class="content">
<p>{{ i18n.ts._mfm.boldDescription }}</p>
<div class="preview">
- <Mfm :text="preview_bold" />
+ <Mfm :text="preview_bold"/>
<MkTextarea v-model="preview_bold"><template #label>MFM</template></MkTextarea>
</div>
</div>
@@ -69,7 +74,7 @@
<div class="content">
<p>{{ i18n.ts._mfm.smallDescription }}</p>
<div class="preview">
- <Mfm :text="preview_small" />
+ <Mfm :text="preview_small"/>
<MkTextarea v-model="preview_small"><template #label>MFM</template></MkTextarea>
</div>
</div>
@@ -79,7 +84,7 @@
<div class="content">
<p>{{ i18n.ts._mfm.quoteDescription }}</p>
<div class="preview">
- <Mfm :text="preview_quote" />
+ <Mfm :text="preview_quote"/>
<MkTextarea v-model="preview_quote"><template #label>MFM</template></MkTextarea>
</div>
</div>
@@ -89,7 +94,7 @@
<div class="content">
<p>{{ i18n.ts._mfm.centerDescription }}</p>
<div class="preview">
- <Mfm :text="preview_center" />
+ <Mfm :text="preview_center"/>
<MkTextarea v-model="preview_center"><template #label>MFM</template></MkTextarea>
</div>
</div>
@@ -99,7 +104,7 @@
<div class="content">
<p>{{ i18n.ts._mfm.inlineCodeDescription }}</p>
<div class="preview">
- <Mfm :text="preview_inlineCode" />
+ <Mfm :text="preview_inlineCode"/>
<MkTextarea v-model="preview_inlineCode"><template #label>MFM</template></MkTextarea>
</div>
</div>
@@ -109,7 +114,7 @@
<div class="content">
<p>{{ i18n.ts._mfm.blockCodeDescription }}</p>
<div class="preview">
- <Mfm :text="preview_blockCode" />
+ <Mfm :text="preview_blockCode"/>
<MkTextarea v-model="preview_blockCode"><template #label>MFM</template></MkTextarea>
</div>
</div>
@@ -119,7 +124,7 @@
<div class="content">
<p>{{ i18n.ts._mfm.inlineMathDescription }}</p>
<div class="preview">
- <Mfm :text="preview_inlineMath" />
+ <Mfm :text="preview_inlineMath"/>
<MkTextarea v-model="preview_inlineMath"><template #label>MFM</template></MkTextarea>
</div>
</div>
@@ -129,7 +134,7 @@
<div class="content">
<p>{{ i18n.ts._mfm.blockMathDescription }}</p>
<div class="preview">
- <Mfm :text="preview_blockMath" />
+ <Mfm :text="preview_blockMath"/>
<MkTextarea v-model="preview_blockMath"><template #label>MFM</template></MkTextarea>
</div>
</div>
@@ -139,7 +144,7 @@
<div class="content">
<p>{{ i18n.ts._mfm.searchDescription }}</p>
<div class="preview">
- <Mfm :text="preview_search" />
+ <Mfm :text="preview_search"/>
<MkTextarea v-model="preview_search"><template #label>MFM</template></MkTextarea>
</div>
</div>
@@ -149,7 +154,7 @@
<div class="content">
<p>{{ i18n.ts._mfm.flipDescription }}</p>
<div class="preview">
- <Mfm :text="preview_flip" />
+ <Mfm :text="preview_flip"/>
<MkTextarea v-model="preview_flip"><template #label>MFM</template></MkTextarea>
</div>
</div>
@@ -159,7 +164,7 @@
<div class="content">
<p>{{ i18n.ts._mfm.fontDescription }}</p>
<div class="preview">
- <Mfm :text="preview_font" />
+ <Mfm :text="preview_font"/>
<MkTextarea v-model="preview_font"><template #label>MFM</template></MkTextarea>
</div>
</div>
@@ -169,7 +174,7 @@
<div class="content">
<p>{{ i18n.ts._mfm.x2Description }}</p>
<div class="preview">
- <Mfm :text="preview_x2" />
+ <Mfm :text="preview_x2"/>
<MkTextarea v-model="preview_x2"><template #label>MFM</template></MkTextarea>
</div>
</div>
@@ -179,7 +184,7 @@
<div class="content">
<p>{{ i18n.ts._mfm.x3Description }}</p>
<div class="preview">
- <Mfm :text="preview_x3" />
+ <Mfm :text="preview_x3"/>
<MkTextarea v-model="preview_x3"><template #label>MFM</template></MkTextarea>
</div>
</div>
@@ -189,7 +194,7 @@
<div class="content">
<p>{{ i18n.ts._mfm.x4Description }}</p>
<div class="preview">
- <Mfm :text="preview_x4" />
+ <Mfm :text="preview_x4"/>
<MkTextarea v-model="preview_x4"><template #label>MFM</template></MkTextarea>
</div>
</div>
@@ -199,7 +204,7 @@
<div class="content">
<p>{{ i18n.ts._mfm.blurDescription }}</p>
<div class="preview">
- <Mfm :text="preview_blur" />
+ <Mfm :text="preview_blur"/>
<MkTextarea v-model="preview_blur"><template #label>MFM</template></MkTextarea>
</div>
</div>
@@ -209,7 +214,7 @@
<div class="content">
<p>{{ i18n.ts._mfm.jellyDescription }}</p>
<div class="preview">
- <Mfm :text="preview_jelly" />
+ <Mfm :text="preview_jelly"/>
<MkTextarea v-model="preview_jelly"><template #label>MFM</template></MkTextarea>
</div>
</div>
@@ -219,7 +224,7 @@
<div class="content">
<p>{{ i18n.ts._mfm.tadaDescription }}</p>
<div class="preview">
- <Mfm :text="preview_tada" />
+ <Mfm :text="preview_tada"/>
<MkTextarea v-model="preview_tada"><template #label>MFM</template></MkTextarea>
</div>
</div>
@@ -229,7 +234,7 @@
<div class="content">
<p>{{ i18n.ts._mfm.jumpDescription }}</p>
<div class="preview">
- <Mfm :text="preview_jump" />
+ <Mfm :text="preview_jump"/>
<MkTextarea v-model="preview_jump"><template #label>MFM</template></MkTextarea>
</div>
</div>
@@ -239,7 +244,7 @@
<div class="content">
<p>{{ i18n.ts._mfm.bounceDescription }}</p>
<div class="preview">
- <Mfm :text="preview_bounce" />
+ <Mfm :text="preview_bounce"/>
<MkTextarea v-model="preview_bounce"><template #label>MFM</template></MkTextarea>
</div>
</div>
@@ -249,7 +254,7 @@
<div class="content">
<p>{{ i18n.ts._mfm.spinDescription }}</p>
<div class="preview">
- <Mfm :text="preview_spin" />
+ <Mfm :text="preview_spin"/>
<MkTextarea v-model="preview_spin"><template #label>MFM</template></MkTextarea>
</div>
</div>
@@ -259,7 +264,7 @@
<div class="content">
<p>{{ i18n.ts._mfm.shakeDescription }}</p>
<div class="preview">
- <Mfm :text="preview_shake" />
+ <Mfm :text="preview_shake"/>
<MkTextarea v-model="preview_shake"><template #label>MFM</template></MkTextarea>
</div>
</div>
@@ -269,7 +274,7 @@
<div class="content">
<p>{{ i18n.ts._mfm.twitchDescription }}</p>
<div class="preview">
- <Mfm :text="preview_twitch" />
+ <Mfm :text="preview_twitch"/>
<MkTextarea v-model="preview_twitch"><template #label>MFM</template></MkTextarea>
</div>
</div>
@@ -279,7 +284,7 @@
<div class="content">
<p>{{ i18n.ts._mfm.rainbowDescription }}</p>
<div class="preview">
- <Mfm :text="preview_rainbow" />
+ <Mfm :text="preview_rainbow"/>
<MkTextarea v-model="preview_rainbow"><template #label>MFM</template></MkTextarea>
</div>
</div>
@@ -289,7 +294,7 @@
<div class="content">
<p>{{ i18n.ts._mfm.sparkleDescription }}</p>
<div class="preview">
- <Mfm :text="preview_sparkle" />
+ <Mfm :text="preview_sparkle"/>
<MkTextarea v-model="preview_sparkle"><span>MFM</span></MkTextarea>
</div>
</div>
@@ -299,37 +304,69 @@
<div class="content">
<p>{{ i18n.ts._mfm.rotateDescription }}</p>
<div class="preview">
- <Mfm :text="preview_rotate" />
+ <Mfm :text="preview_rotate"/>
<MkTextarea v-model="preview_rotate"><span>MFM</span></MkTextarea>
</div>
</div>
</div>
<div class="section _block">
+ <div class="title">{{ i18n.ts._mfm.crop }}</div>
+ <div class="content">
+ <p>{{ i18n.ts._mfm.cropDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_crop" />
+ <MkTextarea v-model="preview_crop"><span>MFM</span></MkTextarea>
+ </div>
+ </div>
+ </div>
+ <div class="section _block">
<div class="title">{{ i18n.ts._mfm.position }}</div>
<div class="content">
<p>{{ i18n.ts._mfm.positionDescription }}</p>
<div class="preview">
- <Mfm :text="preview_position" />
+ <Mfm :text="preview_position"/>
<MkTextarea v-model="preview_position"><span>MFM</span></MkTextarea>
</div>
</div>
</div>
+ <div class="section _block" style="overflow: hidden">
+ <div class="title">{{ i18n.ts._mfm.followMouse }}</div>
+ <MkInfo warn>{{ i18n.ts._mfm.uncommonFeature }}</MkInfo>
+ <br/>
+ <div class="content">
+ <p>{{ i18n.ts._mfm.followMouseDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_followmouse"/>
+ <MkTextarea v-model="preview_followmouse"><span>MFM</span></MkTextarea>
+ </div>
+ </div>
+ </div>
<div class="section _block">
<div class="title">{{ i18n.ts._mfm.scale }}</div>
<div class="content">
<p>{{ i18n.ts._mfm.scaleDescription }}</p>
<div class="preview">
- <Mfm :text="preview_scale" />
+ <Mfm :text="preview_scale"/>
<MkTextarea v-model="preview_scale"><span>MFM</span></MkTextarea>
</div>
</div>
</div>
<div class="section _block">
+ <div class="title">{{ i18n.ts._mfm.fade }}</div>
+ <div class="content">
+ <p>{{ i18n.ts._mfm.fadeDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_fade" />
+ <MkTextarea v-model="preview_fade"><span>MFM</span></MkTextarea>
+ </div>
+ </div>
+ </div>
+ <div class="section _block">
<div class="title">{{ i18n.ts._mfm.foreground }}</div>
<div class="content">
<p>{{ i18n.ts._mfm.foregroundDescription }}</p>
<div class="preview">
- <Mfm :text="preview_fg" />
+ <Mfm :text="preview_fg"/>
<MkTextarea v-model="preview_fg"><span>MFM</span></MkTextarea>
</div>
</div>
@@ -339,7 +376,7 @@
<div class="content">
<p>{{ i18n.ts._mfm.backgroundDescription }}</p>
<div class="preview">
- <Mfm :text="preview_bg" />
+ <Mfm :text="preview_bg"/>
<MkTextarea v-model="preview_bg"><span>MFM</span></MkTextarea>
</div>
</div>
@@ -349,7 +386,7 @@
<div class="content">
<p>{{ i18n.ts._mfm.plainDescription }}</p>
<div class="preview">
- <Mfm :text="preview_plain" />
+ <Mfm :text="preview_plain"/>
<MkTextarea v-model="preview_plain"><span>MFM</span></MkTextarea>
</div>
</div>
@@ -362,18 +399,19 @@
<script lang="ts" setup>
import { ref } from 'vue';
+import MkInfo from './MkInfo.vue';
import MkWindow from '@/components/MkWindow.vue';
import MkTextarea from '@/components/MkTextarea.vue';
-import { i18n } from "@/i18n.js";
+import { i18n } from '@/i18n.js';
const emit = defineEmits<{
(ev: 'closed'): void;
}>();
-const preview_mention = ref("@example");
-const preview_hashtag = ref("#test");
+const preview_mention = ref('@example');
+const preview_hashtag = ref('#test');
const preview_link = ref(`[${i18n.ts._mfm.dummy}](https://joinsharkey.org)`);
-const preview_emoji = ref(`:heart:`);
+const preview_emoji = ref(':heart:');
const preview_bold = ref(`**${i18n.ts._mfm.dummy}**`);
const preview_small = ref(
`<small>${i18n.ts._mfm.dummy}</small>`,
@@ -386,33 +424,33 @@ const preview_blockCode = ref(
'```\n~ (#i, 100) {\n\t<: ? ((i % 15) = 0) "FizzBuzz"\n\t\t.? ((i % 3) = 0) "Fizz"\n\t\t.? ((i % 5) = 0) "Buzz"\n\t\t. i\n}\n```',
);
const preview_inlineMath = ref(
- "\\(x= \\frac{-b' \\pm \\sqrt{(b')^2-ac}}{a}\\)",
+ '\\(x= \\frac{-b\' \\pm \\sqrt{(b\')^2-ac}}{a}\\)',
);
-const preview_blockMath = ref("\\[x= \\frac{-b' \\pm \\sqrt{(b')^2-ac}}{a}\\]");
+const preview_blockMath = ref('\\[x= \\frac{-b\' \\pm \\sqrt{(b\')^2-ac}}{a}\\]');
const preview_quote = ref(`> ${i18n.ts._mfm.dummy}`);
const preview_search = ref(
`${i18n.ts._mfm.dummy} [search]\n${i18n.ts._mfm.dummy} [検索]`,
);
const preview_jelly = ref(
- "$[jelly 🍮] $[jelly.speed=3s 🍮] $[jelly.delay=3s 🍮] $[jelly.loop=3 🍮]",
+ '$[jelly 🍮] $[jelly.speed=3s 🍮] $[jelly.delay=3s 🍮] $[jelly.loop=3 🍮]',
);
const preview_tada = ref(
- "$[tada 🍮] $[tada.speed=3s 🍮] $[tada.delay=3s 🍮] $[tada.loop=3 🍮]",
+ '$[tada 🍮] $[tada.speed=3s 🍮] $[tada.delay=3s 🍮] $[tada.loop=3 🍮]',
);
const preview_jump = ref(
- "$[jump 🍮] $[jump.speed=3s 🍮] $[jump.delay=3s 🍮] $[jump.loop=3 🍮]",
+ '$[jump 🍮] $[jump.speed=3s 🍮] $[jump.delay=3s 🍮] $[jump.loop=3 🍮]',
);
const preview_bounce = ref(
- "$[bounce 🍮] $[bounce.speed=3s 🍮] $[bounce.delay=3s 🍮] $[bounce.loop=3 🍮]",
+ '$[bounce 🍮] $[bounce.speed=3s 🍮] $[bounce.delay=3s 🍮] $[bounce.loop=3 🍮]',
);
const preview_shake = ref(
- "$[shake 🍮] $[shake.speed=3s 🍮] $[shake.delay=3s 🍮] $[shake.loop=3 🍮]",
+ '$[shake 🍮] $[shake.speed=3s 🍮] $[shake.delay=3s 🍮] $[shake.loop=3 🍮]',
);
const preview_twitch = ref(
- "$[twitch 🍮] $[twitch.speed=3s 🍮] $[twitch.delay=3s 🍮] $[twitch.loop=3 🍮]",
+ '$[twitch 🍮] $[twitch.speed=3s 🍮] $[twitch.delay=3s 🍮] $[twitch.loop=3 🍮]',
);
const preview_spin = ref(
- "$[spin 🍮] $[spin.left 🍮] $[spin.alternate 🍮]\n$[spin.x 🍮] $[spin.x,left 🍮] $[spin.x,alternate 🍮]\n$[spin.y 🍮] $[spin.y,left 🍮] $[spin.y,alternate 🍮]\n\n$[spin.speed=3s 🍮] $[spin.delay=3s 🍮] $[spin.loop=3 🍮]",
+ '$[spin 🍮] $[spin.left 🍮] $[spin.alternate 🍮]\n$[spin.x 🍮] $[spin.x,left 🍮] $[spin.x,alternate 🍮]\n$[spin.y 🍮] $[spin.y,left 🍮] $[spin.y,alternate 🍮]\n\n$[spin.speed=3s 🍮] $[spin.delay=3s 🍮] $[spin.loop=3 🍮]',
);
const preview_flip = ref(
`$[flip ${i18n.ts._mfm.dummy}]\n$[flip.v ${i18n.ts._mfm.dummy}]\n$[flip.h,v ${i18n.ts._mfm.dummy}]`,
@@ -420,26 +458,31 @@ const preview_flip = ref(
const preview_font = ref(
`$[font.serif ${i18n.ts._mfm.dummy}]\n$[font.monospace ${i18n.ts._mfm.dummy}]`,
);
-const preview_x2 = ref("$[x2 🍮]");
-const preview_x3 = ref("$[x3 🍮]");
-const preview_x4 = ref("$[x4 🍮]");
+const preview_x2 = ref('$[x2 🍮]');
+const preview_x3 = ref('$[x3 🍮]');
+const preview_x4 = ref('$[x4 🍮]');
const preview_blur = ref(`$[blur ${i18n.ts._mfm.dummy}]`);
const preview_rainbow = ref(
- "$[rainbow 🍮] $[rainbow.speed=3s 🍮] $[rainbow.delay=3s 🍮] $[rainbow.loop=3 🍮]",
+ '$[rainbow 🍮] $[rainbow.speed=3s 🍮] $[rainbow.delay=3s 🍮] $[rainbow.loop=3 🍮]',
);
-const preview_sparkle = ref("$[sparkle 🍮]");
+const preview_sparkle = ref('$[sparkle 🍮]');
const preview_rotate = ref(
- "$[rotate 🍮]\n$[rotate.deg=45 🍮]\n$[rotate.x,deg=45 Hello, world!]",
+ '$[rotate 🍮]\n$[rotate.deg=45 🍮]\n$[rotate.x,deg=45 Hello, world!]',
+);
+const preview_position = ref('$[position.y=-1 🍮]\n$[position.x=-1 🍮]');
+const preview_crop = ref(
+ "$[crop.top=50 🍮] $[crop.right=50 🍮] $[crop.bottom=50 🍮] $[crop.left=50 🍮]",
);
-const preview_position = ref("$[position.y=-1 🍮]\n$[position.x=-1 🍮]");
+const preview_followmouse = ref('$[followmouse.x 🍮]\n$[followmouse.x,y,rotateByVelocity,speed=0.4 🍮]');
const preview_scale = ref(
- "$[scale.x=1.3 🍮]\n$[scale.x=1.5,y=3 🍮]\n$[scale.y=0.3 🍮]",
+ '$[scale.x=1.3 🍮]\n$[scale.x=1.5,y=3 🍮]\n$[scale.y=0.3 🍮]',
);
-const preview_fg = ref("$[fg.color=eb6f92 Text color]");
-const preview_bg = ref("$[bg.color=31748f Background color]");
+const preview_fg = ref('$[fg.color=eb6f92 Text color]');
+const preview_bg = ref('$[bg.color=31748f Background color]');
const preview_plain = ref(
- "<plain>**bold** @mention #hashtag `code` $[x2 🍮]</plain>",
+ '<plain>**bold** @mention #hashtag `code` $[x2 🍮]</plain>',
);
+const preview_fade = ref(`$[fade 🍮] $[fade.out 🍮] $[fade.speed=3s 🍮] $[fade.delay=3s 🍮]`);
</script>
<style lang="scss" scoped>
diff --git a/packages/frontend/src/components/MkModPlayer.vue b/packages/frontend/src/components/MkModPlayer.vue
index 75053cbc37..58a96cfb63 100644
--- a/packages/frontend/src/components/MkModPlayer.vue
+++ b/packages/frontend/src/components/MkModPlayer.vue
@@ -1,3 +1,8 @@
+<!--
+SPDX-FileCopyrightText: marie and other Sharkey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
<template>
<div v-if="hide" class="mod-player-disabled" @click="toggleVisible()">
<div>
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 9a667c3118..fe85307a52 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -12,7 +12,14 @@ SPDX-License-Identifier: AGPL-3.0-only
:class="[$style.root, { [$style.showActionsOnlyHover]: defaultStore.state.showNoteActionsOnlyHover }]"
:tabindex="!isDeleted ? '-1' : undefined"
>
- <MkNoteSub v-if="appearNote.reply && !renoteCollapsed" :note="appearNote.reply" :class="$style.replyTo"/>
+ <div v-if="appearNote.reply && inReplyToCollapsed" :class="$style.collapsedInReplyTo">
+ <MkAvatar :class="$style.collapsedInReplyToAvatar" :user="appearNote.reply.user" link preview/>
+ <MkA v-user-preview="note.user.id" :class="$style.name" :to="userPage(note.user)">
+ <MkAcct :user="appearNote.reply.user"/>
+ </MkA>:
+ <Mfm :text="getNoteSummary(appearNote.reply)" :plain="true" :nowrap="true" :author="appearNote.reply.user" :nyaize="'respect'" :class="$style.collapsedInReplyToText" @click="inReplyToCollapsed = false"/>
+ </div>
+ <MkNoteSub v-if="appearNote.reply && !renoteCollapsed && !inReplyToCollapsed" :note="appearNote.reply" :class="$style.replyTo"/>
<div v-if="pinned" :class="$style.tip"><i class="ph-push-pin ph-bold ph-lg"></i> {{ i18n.ts.pinnedNote }}</div>
<!--<div v-if="appearNote._prId_" class="tip"><i class="ph-megaphone ph-bold ph-lg"></i> {{ i18n.ts.promotion }}<button class="_textButton hide" @click="readPromo()">{{ i18n.ts.hideThisNote }} <i class="ph-x ph-bold ph-lg"></i></button></div>-->
<!--<div v-if="appearNote._featuredId_" class="tip"><i class="ph-lightning ph-bold ph-lg"></i> {{ i18n.ts.featured }}</div>-->
@@ -44,7 +51,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div v-if="renoteCollapsed" :class="$style.collapsedRenoteTarget">
<MkAvatar :class="$style.collapsedRenoteTargetAvatar" :user="appearNote.user" link preview/>
- <Mfm :text="getNoteSummary(appearNote)" :plain="true" :nowrap="true" :author="appearNote.user" :nyaize="'respect'" :class="$style.collapsedRenoteTargetText" @click="renoteCollapsed = false"/>
+ <Mfm :text="getNoteSummary(appearNote)" :isBlock="true" :plain="true" :nowrap="true" :author="appearNote.user" :nyaize="'respect'" :class="$style.collapsedRenoteTargetText" @click="renoteCollapsed = false; inReplyToCollapsed = false"/>
</div>
<article v-else :class="$style.article" @contextmenu.stop="onContextmenu">
<div v-if="appearNote.channel" :class="$style.colorBar" :style="{ background: appearNote.channel.color }"></div>
@@ -53,8 +60,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkNoteHeader :note="appearNote" :mini="true" @click.stop/>
<MkInstanceTicker v-if="showTicker" :instance="appearNote.user.instance"/>
<div style="container-type: inline-size;">
+ <bdi>
<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'"/>
+ <Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :isBlock="true" :author="appearNote.user" :nyaize="'respect'"/>
<MkCwButton v-model="showContent" :text="appearNote.text" :renote="appearNote.renote" :files="appearNote.files" :poll="appearNote.poll" style="margin: 4px 0;" @click.stop/>
</p>
<div v-show="appearNote.cw == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]">
@@ -71,12 +79,13 @@ SPDX-License-Identifier: AGPL-3.0-only
:enableEmojiMenu="true"
:enableEmojiMenuReaction="true"
:isAnim="allowAnim"
+ :isBlock="true"
/>
<div v-if="translating || translation" :class="$style.translation">
<MkLoading v-if="translating" mini/>
<div v-else-if="translation">
<b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b>
- <Mfm :text="translation.text" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/>
+ <Mfm :text="translation.text" :isBlock="true" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/>
</div>
</div>
<MkButton v-if="!allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton>
@@ -86,7 +95,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkMediaList :mediaList="appearNote.files" @click.stop/>
</div>
<MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll" @click.stop/>
- <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview" @click.stop/>
+ <div v-if="isEnabledUrlPreview">
+ <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview" @click.stop/>
+ </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.stop @click="collapsed = false">
<span :class="$style.collapsedLabel">{{ i18n.ts.showMore }}</span>
@@ -96,16 +107,17 @@ SPDX-License-Identifier: AGPL-3.0-only
</button>
</div>
<MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ph-television ph-bold ph-lg"></i> {{ appearNote.channel.name }}</MkA>
+ </bdi>
</div>
- <MkReactionsViewer :note="appearNote" :maxNumber="16" @click.stop @mockUpdateMyReaction="emitUpdReaction">
+ <MkReactionsViewer v-if="appearNote.reactionAcceptance !== 'likeOnly'" :note="appearNote" :maxNumber="16" @click.stop @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.stop @click="reply()">
<i class="ph-arrow-u-up-left ph-bold ph-lg"></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"
@@ -117,7 +129,7 @@ SPDX-License-Identifier: AGPL-3.0-only
@mousedown="renoted ? undoRenote(appearNote) : boostVisibility()"
>
<i class="ph-rocket-launch ph-bold ph-lg"></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="ph-prohibit ph-bold ph-lg"></i>
@@ -135,12 +147,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<button v-if="appearNote.myReaction == null && appearNote.reactionAcceptance !== 'likeOnly'" ref="likeButton" :class="$style.footerButton" class="_button" @click.stop @click="like()">
<i class="ph-heart ph-bold ph-lg"></i>
</button>
- <button v-if="appearNote.myReaction == null" ref="reactButton" :class="$style.footerButton" class="_button" @mousedown="react()">
- <i v-if="appearNote.reactionAcceptance === 'likeOnly'" class="ph-heart ph-bold ph-lg"></i>
+ <button ref="reactButton" :class="$style.footerButton" class="_button" @click="toggleReact()" @click.stop>
+ <i v-if="appearNote.reactionAcceptance === 'likeOnly' && appearNote.myReaction != null" class="ph-heart ph-bold ph-lg" style="color: var(--eventReactionHeart);"></i>
+ <i v-else-if="appearNote.myReaction != null" class="ph-minus ph-bold ph-lg" style="color: var(--accent);"></i>
+ <i v-else-if="appearNote.reactionAcceptance === 'likeOnly'" class="ph-heart ph-bold ph-lg"></i>
<i v-else class="ph-smiley ph-bold ph-lg"></i>
- </button>
- <button v-if="appearNote.myReaction != null" ref="reactButton" :class="$style.footerButton" class="_button" @click.stop @click="undoReact(appearNote)">
- <i class="ph-minus ph-bold ph-lg"></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="ph-paperclip ph-bold ph-lg"></i>
@@ -184,6 +196,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';
@@ -195,9 +208,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';
@@ -217,6 +231,7 @@ import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
import { shouldCollapsed } from '@/scripts/collapsed.js';
import { useRouter } from '@/router/supplier.js';
import { boostMenuItems, type Visibility } from '@/scripts/boost-quote.js';
+import { isEnabledUrlPreview } from '@/instance.js';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
@@ -270,6 +285,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 &&
@@ -305,8 +321,9 @@ 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)
- )
+ ),
);
+const inReplyToCollapsed = ref(defaultStore.state.collapseNotesRepliedTo);
const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null);
const animated = computed(() => parsed.value ? checkAnimationFromMfm(parsed.value) : null);
const allowAnim = ref(defaultStore.state.advancedMfm && defaultStore.state.animatedMfm ? true : false);
@@ -328,10 +345,12 @@ function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string
return false;
}
+let renoting = false;
+
const keymap = {
'r': () => reply(true),
'e|a|plus': () => react(true),
- 'q': () => renote(appearNote.value.visibility),
+ '(q)': () => { if (canRenote.value && !renoted.value && !renoting) renote(defaultStore.state.visibilityOnBoost); },
'up|k|shift+tab': focusBefore,
'down|j|tab': focusAfter,
'esc': blur,
@@ -406,9 +425,33 @@ if (!props.mock) {
renoted.value = res.length > 0;
});
}
+
+ 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 boostVisibility() {
+ if (renoting) return;
+
if (!defaultStore.state.showVisibilitySelectorOnBoost) {
renote(defaultStore.state.visibilityOnBoost);
} else {
@@ -420,6 +463,8 @@ function renote(visibility: Visibility, localOnly: boolean = false) {
pleaseLogin();
showMovedDialog();
+ renoting = true;
+
if (appearNote.value.channel) {
const el = renoteButton.value as HTMLElement | null | undefined;
if (el) {
@@ -436,7 +481,7 @@ function renote(visibility: Visibility, localOnly: boolean = false) {
}).then(() => {
os.toast(i18n.ts.renoted);
renoted.value = true;
- });
+ }).finally(() => { renoting = false; });
}
} else if (!appearNote.value.channel || appearNote.value.channel.allowRenoteToExternal) {
const el = renoteButton.value as HTMLElement | null | undefined;
@@ -455,7 +500,7 @@ function renote(visibility: Visibility, localOnly: boolean = false) {
}).then(() => {
os.toast(i18n.ts.renoted);
renoted.value = true;
- });
+ }).finally(() => { renoting = false; });
}
}
}
@@ -626,6 +671,14 @@ function undoRenote(note) : void {
}
}
+function toggleReact() {
+ if (appearNote.value.myReaction == null) {
+ react();
+ } else {
+ undoReact(appearNote.value);
+ }
+}
+
function onContextmenu(ev: MouseEvent): void {
if (props.mock) {
return;
@@ -914,7 +967,7 @@ function emitUpdReaction(emoji: string, delta: number) {
margin-right: 4px;
}
-.collapsedRenoteTarget {
+.collapsedRenoteTarget, .collapsedInReplyTo {
display: flex;
align-items: center;
line-height: 28px;
@@ -922,7 +975,12 @@ function emitUpdReaction(emoji: string, delta: number) {
padding: 0 32px 18px;
}
-.collapsedRenoteTargetAvatar {
+.collapsedInReplyTo {
+ padding: 28px 32px 0;
+ opacity: 0.7;
+}
+
+.collapsedRenoteTargetAvatar, .collapsedInReplyToAvatar {
flex-shrink: 0;
display: inline-block;
width: 28px;
@@ -931,12 +989,15 @@ function emitUpdReaction(emoji: string, delta: number) {
}
.collapsedRenoteTargetText {
+ opacity: 0.7;
+}
+
+.collapsedRenoteTargetText, .collapsedInReplyToText {
overflow: hidden;
flex-shrink: 1;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 90%;
- opacity: 0.7;
cursor: pointer;
&:hover {
@@ -1149,6 +1210,10 @@ function emitUpdReaction(emoji: string, delta: number) {
margin-top: 4px;
}
+ .collapsedInReplyTo {
+ padding: 14px 16px 0;
+ }
+
.article {
padding: 14px 16px;
}
@@ -1219,10 +1284,9 @@ 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%;
}
.clickToOpen {
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index 3d15f69f73..f0b1ca82a4 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -68,8 +68,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</header>
<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"/>
+ <Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :isBlock="true" :author="appearNote.user" :nyaize="'respect'"/>
+ <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>
@@ -84,13 +84,14 @@ SPDX-License-Identifier: AGPL-3.0-only
:enableEmojiMenu="true"
:enableEmojiMenuReaction="true"
:isAnim="allowAnim"
+ :isBlock="true"
/>
<a v-if="appearNote.renote != null" :class="$style.rn">RN:</a>
<div v-if="translating || translation" :class="$style.translation">
<MkLoading v-if="translating" mini/>
<div v-else-if="translation">
<b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b>
- <Mfm :text="translation.text" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/>
+ <Mfm :text="translation.text" :isBlock="true" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/>
</div>
</div>
<MkButton v-if="!allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton>
@@ -99,7 +100,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" :expandAllCws="props.expandAllCws"/></div>
</div>
<MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ph-television ph-bold ph-lg"></i> {{ appearNote.channel.name }}</MkA>
@@ -113,10 +116,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="ph-arrow-u-up-left ph-bold ph-lg"></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"
@@ -127,7 +130,7 @@ SPDX-License-Identifier: AGPL-3.0-only
@mousedown="renoted ? undoRenote() : boostVisibility()"
>
<i class="ph-rocket-launch ph-bold ph-lg"></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="ph-prohibit ph-bold ph-lg"></i>
@@ -144,12 +147,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<button v-if="appearNote.myReaction == null && appearNote.reactionAcceptance !== 'likeOnly'" ref="likeButton" :class="$style.noteFooterButton" class="_button" @mousedown="like()">
<i class="ph-heart ph-bold ph-lg"></i>
</button>
- <button v-if="appearNote.myReaction == null" ref="reactButton" :class="$style.noteFooterButton" class="_button" @mousedown="react()">
- <i v-if="appearNote.reactionAcceptance === 'likeOnly'" class="ph-heart ph-bold ph-lg"></i>
+ <button ref="reactButton" :class="$style.noteFooterButton" class="_button" @click="toggleReact()">
+ <i v-if="appearNote.reactionAcceptance === 'likeOnly' && appearNote.myReaction != null" class="ph-heart ph-bold ph-lg" style="color: var(--eventReactionHeart);"></i>
+ <i v-else-if="appearNote.myReaction != null" class="ph-minus ph-bold ph-lg" style="color: var(--accent);"></i>
+ <i v-else-if="appearNote.reactionAcceptance === 'likeOnly'" class="ph-heart ph-bold ph-lg"></i>
<i v-else class="ph-smiley ph-bold ph-lg"></i>
- </button>
- <button v-if="appearNote.myReaction != null" ref="reactButton" class="_button" :class="[$style.noteFooterButton, $style.reacted]" @click="undoReact(appearNote)">
- <i class="ph-minus ph-bold ph-lg"></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="ph-paperclip ph-bold ph-lg"></i>
@@ -226,6 +229,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';
@@ -236,8 +240,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';
@@ -258,11 +263,15 @@ import MkPagination, { type Paging } from '@/components/MkPagination.vue';
import MkReactionIcon from '@/components/MkReactionIcon.vue';
import MkButton from '@/components/MkButton.vue';
import { boostMenuItems, type Visibility } from '@/scripts/boost-quote.js';
+import { isEnabledUrlPreview } from '@/instance.js';
-const props = defineProps<{
+const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
expandAllCws?: boolean;
-}>();
+ initialTab: string;
+}>(), {
+ initialTab: 'replies',
+});
const inChannel = inject('inChannel', null);
@@ -289,7 +298,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
);
@@ -336,10 +347,12 @@ if ($i) {
});
}
+let renoting = false;
+
const keymap = {
'r': () => reply(true),
'e|a|plus': () => react(true),
- 'q': () => renote(appearNote.value.visibility),
+ '(q)': () => { if (canRenote.value && !renoted.value && !renoting) renote(defaultStore.state.visibilityOnBoost); },
'esc': blur,
'm|o': () => showMenu(true),
's': () => showContent.value !== showContent.value,
@@ -352,7 +365,7 @@ provide('react', (reaction: string) => {
});
});
-const tab = ref('replies');
+const tab = ref(props.initialTab);
const reactionTabType = ref<string | null>(null);
const renotesPagination = computed<Paging>(() => ({
@@ -431,6 +444,8 @@ useTooltip(quoteButton, async (showing) => {
});
function boostVisibility() {
+ if (renoting) return;
+
if (!defaultStore.state.showVisibilitySelectorOnBoost) {
renote(defaultStore.state.visibilityOnBoost);
} else {
@@ -438,10 +453,34 @@ function boostVisibility() {
}
}
+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(visibility: Visibility, localOnly: boolean = false) {
pleaseLogin();
showMovedDialog();
+ renoting = true;
+
if (appearNote.value.channel) {
const el = renoteButton.value as HTMLElement | null | undefined;
if (el) {
@@ -457,7 +496,7 @@ function renote(visibility: Visibility, localOnly: boolean = false) {
}).then(() => {
os.toast(i18n.ts.renoted);
renoted.value = true;
- });
+ }).finally(() => { renoting = false; });
} else if (!appearNote.value.channel || appearNote.value.channel.allowRenoteToExternal) {
const el = renoteButton.value as HTMLElement | null | undefined;
if (el) {
@@ -474,7 +513,7 @@ function renote(visibility: Visibility, localOnly: boolean = false) {
}).then(() => {
os.toast(i18n.ts.renoted);
renoted.value = true;
- });
+ }).finally(() => { renoting = false; });
}
}
@@ -594,11 +633,11 @@ function like(): 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,
});
}
@@ -619,6 +658,14 @@ function undoRenote() : void {
}
}
+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/MkNotePreview.vue b/packages/frontend/src/components/MkNotePreview.vue
index 3fcd7593ba..a8853a8a5f 100644
--- a/packages/frontend/src/components/MkNotePreview.vue
+++ b/packages/frontend/src/components/MkNotePreview.vue
@@ -12,11 +12,11 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div>
<p v-if="useCw" :class="$style.cw">
- <Mfm v-if="cw != null && cw != ''" :text="cw" :author="user" :nyaize="'respect'" :i="user" style="margin-right: 8px;"/>
+ <Mfm v-if="cw != null && cw != ''" :text="cw" :isBlock="true" :author="user" :nyaize="'respect'" :i="user" style="margin-right: 8px;"/>
<MkCwButton v-model="showContent" :text="text.trim()" :files="files" :poll="poll" style="margin: 4px 0;"/>
</p>
<div v-show="!useCw || showContent">
- <Mfm :text="text.trim()" :author="user" :nyaize="'respect'" :i="user"/>
+ <Mfm :text="text.trim()" :isBlock="true" :author="user" :nyaize="'respect'" :i="user"/>
</div>
</div>
</div>
diff --git a/packages/frontend/src/components/MkNoteSimple.vue b/packages/frontend/src/components/MkNoteSimple.vue
index 477cf4521a..542e3e79ea 100644
--- a/packages/frontend/src/components/MkNoteSimple.vue
+++ b/packages/frontend/src/components/MkNoteSimple.vue
@@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkNoteHeader :class="$style.header" :note="note" :mini="true"/>
<div>
<p v-if="note.cw != null" :class="$style.cw">
- <Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/>
+ <Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :isBlock="true" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/>
<MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll" @click.stop/>
</p>
<div v-show="note.cw == null || showContent">
diff --git a/packages/frontend/src/components/MkNoteSub.vue b/packages/frontend/src/components/MkNoteSub.vue
index 37811dd52e..66d1e51a6c 100644
--- a/packages/frontend/src/components/MkNoteSub.vue
+++ b/packages/frontend/src/components/MkNoteSub.vue
@@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkNoteHeader :class="$style.header" :note="note" :mini="true"/>
<div :class="$style.content">
<p v-if="note.cw != null" :class="$style.cw">
- <Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'"/>
+ <Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :isBlock="true" :author="note.user" :nyaize="'respect'"/>
<MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll"/>
</p>
<div v-show="note.cw == null || showContent">
diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue
index 562cc38bf3..f849e94e93 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', 'edited'].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_reactionGroup]"><i class="ph-smiley ph-bold ph-lg" style="line-height: 1;"></i></div>
<div v-else-if="notification.type === 'reaction:grouped'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ph-smiley ph-bold ph-lg" style="line-height: 1;"></i></div>
<div v-else-if="notification.type === 'renote:grouped'" :class="[$style.icon, $style.icon_renoteGroup]"><i class="ph-rocket-launch ph-bold ph-lg" style="line-height: 1;"></i></div>
<img v-else-if="notification.type === 'test'" :class="$style.icon" :src="infoImageUrl"/>
@@ -60,7 +61,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>
<span v-else-if="notification.type === 'edited'">{{ i18n.ts._notification.edited }}</span>
@@ -69,29 +71,29 @@ SPDX-License-Identifier: AGPL-3.0-only
<div>
<MkA v-if="notification.type === 'reaction' || notification.type === 'reaction:grouped'" :class="$style.text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
<i class="ph-quotes ph-bold ph-lg" :class="$style.quote"></i>
- <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="true" :author="notification.note.user"/>
+ <Mfm :text="getNoteSummary(notification.note)" :isBlock="true" :plain="true" :nowrap="true" :author="notification.note.user"/>
<i class="ph-quotes ph-bold ph-lg" :class="$style.quote"></i>
</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="ph-quotes ph-bold ph-lg" :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)" :isBlock="true" :plain="true" :nowrap="true" :author="notification.note.renote?.user"/>
<i class="ph-quotes ph-bold ph-lg" :class="$style.quote"></i>
</MkA>
<MkA v-else-if="notification.type === 'reply'" :class="$style.text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
- <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="true" :author="notification.note.user"/>
+ <Mfm :text="getNoteSummary(notification.note)" :isBlock="true" :plain="true" :nowrap="true" :author="notification.note.user"/>
</MkA>
<MkA v-else-if="notification.type === 'mention'" :class="$style.text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
- <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="true" :author="notification.note.user"/>
+ <Mfm :text="getNoteSummary(notification.note)" :isBlock="true" :plain="true" :nowrap="true" :author="notification.note.user"/>
</MkA>
<MkA v-else-if="notification.type === 'quote'" :class="$style.text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
- <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="true" :author="notification.note.user"/>
+ <Mfm :text="getNoteSummary(notification.note)" :isBlock="true" :plain="true" :nowrap="true" :author="notification.note.user"/>
</MkA>
<MkA v-else-if="notification.type === 'note'" :class="$style.text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
- <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="true" :author="notification.note.user"/>
+ <Mfm :text="getNoteSummary(notification.note)" :isBlock="true" :plain="true" :nowrap="true" :author="notification.note.user"/>
</MkA>
<MkA v-else-if="notification.type === 'pollEnded'" :class="$style.text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
<i class="ph-quotes ph-bold ph-lg" :class="$style.quote"></i>
- <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="true" :author="notification.note.user"/>
+ <Mfm :text="getNoteSummary(notification.note)" :isBlock="true" :plain="true" :nowrap="true" :author="notification.note.user"/>
<i class="ph-quotes ph-bold ph-lg" :class="$style.quote"></i>
</MkA>
<div v-else-if="notification.type === 'roleAssigned'" :class="$style.text">
@@ -137,7 +139,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkA v-else-if="notification.type === 'edited'" :class="$style.text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
<i class="ph-quotes ph-bold ph-lg" :class="$style.quote"></i>
- <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="true" :author="notification.note.user"/>
+ <Mfm :text="getNoteSummary(notification.note)" :isBlock="true" :plain="true" :nowrap="true" :author="notification.note.user"/>
<i class="ph-quotes ph-bold ph-lg" :class="$style.quote"></i>
</MkA>
</div>
@@ -182,6 +184,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>
@@ -211,6 +218,7 @@ const rejectFollowRequest = () => {
}
.icon_reactionGroup,
+.icon_reactionGroupHeart,
.icon_renoteGroup {
display: grid;
align-items: center;
@@ -223,11 +231,15 @@ const rejectFollowRequest = () => {
}
.icon_reactionGroup {
- background: #e99a0b;
+ background: var(--eventReaction);
+}
+
+.icon_reactionGroupHeart {
+ background: var(--eventReactionHeart);
}
.icon_renoteGroup {
- background: #36d298;
+ background: var(--eventRenote);
}
.icon_app {
@@ -256,49 +268,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/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue
index 6f6007d432..9a324849e2 100644
--- a/packages/frontend/src/components/MkPagination.vue
+++ b/packages/frontend/src/components/MkPagination.vue
@@ -73,7 +73,7 @@ export type Paging<E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints>
*/
reversed?: boolean;
- offsetMode?: boolean;
+ offsetMode?: boolean | ComputedRef<boolean>;
pageEl?: HTMLElement;
};
@@ -240,10 +240,11 @@ const fetchMore = async (): Promise<void> => {
if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return;
moreFetching.value = true;
const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
+ const offsetMode = props.offsetMode ? isRef(props.offsetMode) ? props.offsetMode.value : props.offsetMode : false;
await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, {
...params,
limit: SECOND_FETCH_LIMIT,
- ...(props.pagination.offsetMode ? {
+ ...(offsetMode ? {
offset: offset.value,
} : {
untilId: Array.from(items.value.keys()).at(-1),
@@ -304,10 +305,11 @@ const fetchMoreAhead = async (): Promise<void> => {
if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return;
moreFetching.value = true;
const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
+ const offsetMode = props.offsetMode ? isRef(props.offsetMode) ? props.offsetMode.value : props.offsetMode : false;
await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, {
...params,
limit: SECOND_FETCH_LIMIT,
- ...(props.pagination.offsetMode ? {
+ ...(offsetMode ? {
offset: offset.value,
} : {
sinceId: Array.from(items.value.keys()).at(-1),
diff --git a/packages/frontend/src/components/MkPasswordDialog.vue b/packages/frontend/src/components/MkPasswordDialog.vue
index 3c0cdaa786..3a13326946 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="ph-password ph-bold ph-lg"></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="ph-password ph-bold ph-lg"></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="ph-keyhole ph-bold ph-lg"></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="ph-keyhole ph-bold ph-lg"></i><i v-else class="ph-numpad ph-bold ph-lg"></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="ph-lock ph-bold ph-lg-open"></i> {{ i18n.ts.continue }}</MkButton>
- </div>
+ <MkButton :disabled="(password ?? '') == '' || ($i.twoFactorEnabled && (token ?? '') == '')" type="submit" primary rounded style="margin: 0 auto;"><i class="ph-lock ph-bold ph-lg-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 d9e50fbb79..cfaaeecc34 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -68,7 +68,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<input v-show="useCw" ref="cwInputEl" v-model="cw" :class="$style.cw" :placeholder="i18n.ts.annotation" @keydown="onKeydown">
<div :class="[$style.textOuter, { [$style.withCw]: useCw }]">
<div v-if="channel" :class="$style.colorBar" :style="{ background: channel.color }"></div>
- <textarea ref="textareaEl" v-model="text" :class="[$style.text]" :disabled="posting || posted" :readonly="textAreaReadOnly" :placeholder="placeholder" data-cy-post-form-text @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"/>
+ <textarea ref="textareaEl" v-model="text" :class="[$style.text]" :disabled="posting || posted" :readonly="textAreaReadOnly" :placeholder="placeholder" data-cy-post-form-text dir="auto" @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"/>
<div v-if="maxTextLength - textLength < 100" :class="['_acrylic', $style.textCount, { [$style.textOver]: textLength > maxTextLength }]">{{ maxTextLength - textLength }}</div>
</div>
<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags">
@@ -158,6 +158,7 @@ const props = withDefaults(defineProps<{
initialVisibleUsers: () => [],
autofocus: true,
mock: false,
+ initialLocalOnly: undefined,
});
provide('mock', props.mock);
@@ -187,11 +188,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);
@@ -255,7 +256,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);
});
@@ -331,7 +338,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));
});
}
@@ -388,7 +395,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);
});
}
}
@@ -518,6 +525,9 @@ async function toggleLocalOnly() {
}
localOnly.value = !localOnly.value;
+ if (defaultStore.state.rememberNoteVisibility) {
+ defaultStore.set('localOnly', localOnly.value);
+ }
}
async function toggleReactionAcceptance() {
@@ -608,6 +618,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) {
@@ -679,6 +706,7 @@ function saveDraft() {
localOnly: localOnly.value,
files: files.value,
poll: poll.value,
+ visibleUserIds: visibility.value === 'specified' ? visibleUsers.value.map(x => x.id) : undefined,
},
};
@@ -991,6 +1019,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 5260ac2a08..ad990e21db 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
@@ -32,7 +32,9 @@ const props = defineProps<{
fixed?: boolean;
autofocus?: boolean;
editId?: Misskey.entities.Note["id"];
-}>();
+}>(), {
+ 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 3d3130cd51..a70ed18d18 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;
cursor: auto; /* not clickToOpen-able */
diff --git a/packages/frontend/src/components/MkSignin.vue b/packages/frontend/src/components/MkSignin.vue
index dc68a99593..6f7994dccb 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="ph-lock ph-bold ph-lg"></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="ph-keyhole ph-bold ph-lg"></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="ph-keyhole ph-bold ph-lg"></i><i v-else class="ph-numpad ph-bold ph-lg"></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/MkSubNoteContent.vue b/packages/frontend/src/components/MkSubNoteContent.vue
index 7e63bbe82d..8386a783fc 100644
--- a/packages/frontend/src/components/MkSubNoteContent.vue
+++ b/packages/frontend/src/components/MkSubNoteContent.vue
@@ -9,14 +9,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-if="note.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<span v-if="note.deletedAt" style="opacity: 0.5">({{ i18n.ts.deletedNote }})</span>
<MkA v-if="note.replyId" :class="$style.reply" :to="`/notes/${note.replyId}`" @click.stop><i class="ph-arrow-bend-left-up ph-bold ph-lg"></i></MkA>
- <Mfm v-if="note.text" :text="note.text" :author="note.user" :nyaize="'respect'" :isAnim="allowAnim" :emojiUrls="note.emojis"/>
+ <Mfm v-if="note.text" :text="note.text" :isBlock="true" :author="note.user" :nyaize="'respect'" :isAnim="allowAnim" :emojiUrls="note.emojis"/>
<MkButton v-if="!allowAnim && animated && !hideFiles" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton>
<MkButton v-else-if="!defaultStore.state.animatedMfm && allowAnim && animated && !hideFiles" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton>
<div v-if="note.text && translating || note.text && translation" :class="$style.translation">
<MkLoading v-if="translating" mini/>
<div v-else>
<b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b>
- <Mfm :text="translation.text" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/>
+ <Mfm :text="translation.text" :isBlock="true" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/>
</div>
</div>
<MkA v-if="note.renoteId" :class="$style.rp" :to="`/notes/${note.renoteId}`" @click.stop>RN: ...</MkA>
diff --git a/packages/frontend/src/components/MkSwitch.button.vue b/packages/frontend/src/components/MkSwitch.button.vue
index 21339d1b4e..f7c413e1d3 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: var(--radius-ellipse);
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/MkTextarea.vue b/packages/frontend/src/components/MkTextarea.vue
index 3082842699..7b9fb3d8ad 100644
--- a/packages/frontend/src/components/MkTextarea.vue
+++ b/packages/frontend/src/components/MkTextarea.vue
@@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.caption"><slot name="caption"></slot></div>
<button v-if="mfmPreview" style="font-size: 0.85em;" class="_textButton" type="button" @click="preview = !preview">{{ i18n.ts.preview }}</button>
<div v-if="mfmPreview" v-show="preview" v-panel :class="$style.mfmPreview">
- <Mfm :text="v"/>
+ <Mfm :text="v" :isBlock="true" />
</div>
<MkButton v-if="manualSave && changed" primary :class="$style.save" @click="updated"><i class="ph-floppy-disk ph-bold ph-lg"></i> {{ i18n.ts.save }}</MkButton>
diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue
index 1c14174a37..0f7eb3b86c 100644
--- a/packages/frontend/src/components/MkTimeline.vue
+++ b/packages/frontend/src/components/MkTimeline.vue
@@ -154,6 +154,8 @@ function connectChannel() {
} else if (props.src === 'channel') {
if (props.channel == null) return;
connection = stream.useChannel('channel', {
+ withRenotes: props.withRenotes,
+ withFiles: props.onlyFiles ? true : undefined,
channelId: props.channel,
});
} else if (props.src === 'role') {
@@ -234,6 +236,8 @@ function updatePaginationQuery() {
} else if (props.src === 'channel') {
endpoint = 'channels/timeline';
query = {
+ withRenotes: props.withRenotes,
+ withFiles: props.onlyFiles ? true : undefined,
channelId: props.channel,
};
} else if (props.src === 'role') {
diff --git a/packages/frontend/src/components/MkToast.vue b/packages/frontend/src/components/MkToast.vue
index a117e49350..f731b3264f 100644
--- a/packages/frontend/src/components/MkToast.vue
+++ b/packages/frontend/src/components/MkToast.vue
@@ -14,7 +14,8 @@ SPDX-License-Identifier: AGPL-3.0-only
>
<div v-if="showing" class="_acrylic" :class="$style.root" :style="{ zIndex }">
<div style="padding: 16px 24px;">
- {{ message }}
+ <Mfm v-if="renderMfm" :text="message" plain/>
+ <template v-else>{{ message }}</template>
</div>
</div>
</Transition>
@@ -26,9 +27,12 @@ import { onMounted, ref } from 'vue';
import * as os from '@/os.js';
import { defaultStore } from '@/store.js';
-defineProps<{
+withDefaults(defineProps<{
message: string;
-}>();
+ renderMfm: boolean;
+}>(), {
+ renderMfm: false,
+});
const emit = defineEmits<{
(ev: 'closed'): void;
diff --git a/packages/frontend/src/components/MkTutorialDialog.Note.vue b/packages/frontend/src/components/MkTutorialDialog.Note.vue
index f8139d641e..725cfcdc33 100644
--- a/packages/frontend/src/components/MkTutorialDialog.Note.vue
+++ b/packages/frontend/src/components/MkTutorialDialog.Note.vue
@@ -74,6 +74,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 1771559a9b..b0561d4bae 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 4b4e8ea8f8..f155ad7bcb 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 10ba137b94..2e069fcdd2 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/MkUserInfo.vue b/packages/frontend/src/components/MkUserInfo.vue
index 63c4af41a0..5658188c41 100644
--- a/packages/frontend/src/components/MkUserInfo.vue
+++ b/packages/frontend/src/components/MkUserInfo.vue
@@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-if="$i && $i.id !== user.id && user.isFollowed" :class="$style.followed">{{ i18n.ts.followsYou }}</span>
<div :class="$style.description">
<div v-if="user.description" :class="$style.mfm">
- <Mfm :text="user.description" :author="user"/>
+ <Mfm :text="user.description" :isBlock="true" :author="user"/>
</div>
<span v-else style="opacity: 0.7;">{{ i18n.ts.noAccountDescription }}</span>
</div>
diff --git a/packages/frontend/src/components/MkUserPopup.vue b/packages/frontend/src/components/MkUserPopup.vue
index 6550fc4ec1..2aee918114 100644
--- a/packages/frontend/src/components/MkUserPopup.vue
+++ b/packages/frontend/src/components/MkUserPopup.vue
@@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.username"><MkAcct :user="user"/></div>
</div>
<div :class="$style.description">
- <Mfm v-if="user.description" :nyaize="false" :class="$style.mfm" :text="user.description" :author="user"/>
+ <Mfm v-if="user.description" :nyaize="false" :class="$style.mfm" :text="user.description" :isBlock="true" :author="user"/>
<div v-else style="opacity: 0.7;">{{ i18n.ts.noAccountDescription }}</div>
</div>
<div v-if="user.fields.length > 0" :class="$style.fields">
@@ -118,8 +118,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/MkUserSetupDialog.User.vue b/packages/frontend/src/components/MkUserSetupDialog.User.vue
index a4b9746f4b..efb1ed5593 100644
--- a/packages/frontend/src/components/MkUserSetupDialog.User.vue
+++ b/packages/frontend/src/components/MkUserSetupDialog.User.vue
@@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div :class="$style.description">
<div v-if="user.description" :class="$style.mfm">
- <Mfm :text="user.description" :author="user"/>
+ <Mfm :text="user.description" :isBlock="true" :author="user"/>
</div>
<span v-else style="opacity: 0.7;">{{ i18n.ts.noAccountDescription }}</span>
</div>
diff --git a/packages/frontend/src/components/MkVisitorDashboard.vue b/packages/frontend/src/components/MkVisitorDashboard.vue
index f9f16c594e..b902494025 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 || '/apple-touch-icon.png'" alt="" :class="$style.mainIcon"/>
<button class="_button _acrylic" :class="$style.mainMenu" @click="showMenu"><i class="ph-dots-three ph-bold ph-lg"></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="sanitizeHtml(meta.description) || i18n.ts.headlineMisskey"></div>
+ <div v-html="sanitizeHtml(instance.description) || i18n.ts.headlineMisskey"></div>
</div>
<div v-if="instance.disableRegistration" :class="$style.mainWarn">
<MkInfo warn>{{ i18n.ts.invitationRequiredToRegister }}</MkInfo>
@@ -69,14 +69,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;
});
@@ -94,49 +90,7 @@ function signup() {
}
function showMenu(ev) {
- os.popupMenu([{
- text: i18n.ts.instanceInfo,
- icon: 'ph-info ph-bold ph-lg',
- action: () => {
- os.pageWindow('/about');
- },
- }, {
- text: i18n.ts.aboutMisskey,
- icon: 'sk-icons sk-shark ph-bold',
- action: () => {
- os.pageWindow('/about-sharkey');
- },
- }, { type: 'divider' }, (instance.impressumUrl) ? {
- text: i18n.ts.impressum,
- icon: 'ph-newspaper-clipping ph-bold ph-lg',
- action: () => {
- window.open(instance.impressumUrl!, '_blank', 'noopener');
- },
- } : undefined, (instance.tosUrl) ? {
- text: i18n.ts.termsOfService,
- icon: 'ph-notebook ph-bold ph-lg',
- action: () => {
- window.open(instance.tosUrl!, '_blank', 'noopener');
- },
- } : undefined, (instance.privacyPolicyUrl) ? {
- text: i18n.ts.privacyPolicy,
- icon: 'ph-shield ph-bold ph-lg',
- action: () => {
- window.open(instance.privacyPolicyUrl!, '_blank', 'noopener');
- },
- } : undefined, (instance.donationUrl) ? {
- text: i18n.ts.donation,
- icon: 'ph-hand-coins ph-bold ph-lg',
- action: () => {
- window.open(instance.donationUrl, '_blank', 'noopener');
- },
- } : undefined, (!instance.impressumUrl && !instance.tosUrl && !instance.privacyPolicyUrl && !instance.donationUrl) ? undefined : { type: 'divider' }, {
- text: i18n.ts.help,
- icon: 'ph-question ph-bold ph-lg',
- 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/SkApprovalUser.vue b/packages/frontend/src/components/SkApprovalUser.vue
index f85944cd04..20059f139d 100644
--- a/packages/frontend/src/components/SkApprovalUser.vue
+++ b/packages/frontend/src/components/SkApprovalUser.vue
@@ -1,3 +1,8 @@
+<!--
+SPDX-FileCopyrightText: marie and other Sharkey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
<template>
<MkFolder :expanded="false">
<template #icon><i class="ph-user ph-bold ph-lg"></i></template>
diff --git a/packages/frontend/src/components/SkNote.vue b/packages/frontend/src/components/SkNote.vue
index 09decad1a2..a193df4326 100644
--- a/packages/frontend/src/components/SkNote.vue
+++ b/packages/frontend/src/components/SkNote.vue
@@ -12,7 +12,15 @@ SPDX-License-Identifier: AGPL-3.0-only
:class="[$style.root, { [$style.showActionsOnlyHover]: defaultStore.state.showNoteActionsOnlyHover }]"
:tabindex="!isDeleted ? '-1' : undefined"
>
- <SkNoteSub v-if="appearNote.reply && !renoteCollapsed" :note="appearNote.reply" :class="$style.replyTo"/>
+ <SkNoteSub v-if="appearNote.reply && !renoteCollapsed && !inReplyToCollapsed" :note="appearNote.reply" :class="$style.replyTo"/>
+ <div v-if="appearNote.reply && inReplyToCollapsed && !renoteCollapsed" :class="$style.collapsedInReplyTo">
+ <div :class="$style.collapsedInReplyToLine"></div>
+ <MkAvatar :class="$style.collapsedInReplyToAvatar" :user="appearNote.reply.user" link preview/>
+ <MkA v-user-preview="note.user.id" :class="$style.name" :to="userPage(note.user)">
+ <MkAcct :user="appearNote.reply.user"/>
+ </MkA>:
+ <Mfm :text="getNoteSummary(appearNote.reply)" :plain="true" :nowrap="true" :author="appearNote.reply.user" :nyaize="'respect'" :class="$style.collapsedInReplyToText" @click="inReplyToCollapsed = false"/>
+ </div>
<div v-if="pinned" :class="$style.tip"><i class="ph-push-pin ph-bold ph-lg"></i> {{ i18n.ts.pinnedNote }}</div>
<!--<div v-if="appearNote._prId_" class="tip"><i class="ph-megaphone ph-bold ph-lg"></i> {{ i18n.ts.promotion }}<button class="_textButton hide" @click="readPromo()">{{ i18n.ts.hideThisNote }} <i class="ph-x ph-bold ph-lg"></i></button></div>-->
<!--<div v-if="appearNote._featuredId_" class="tip"><i class="ph-lightning ph-bold ph-lg"></i> {{ i18n.ts.featured }}</div>-->
@@ -44,7 +52,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div v-if="renoteCollapsed" :class="$style.collapsedRenoteTarget">
<MkAvatar :class="$style.collapsedRenoteTargetAvatar" :user="appearNote.user" link preview/>
- <Mfm :text="getNoteSummary(appearNote)" :plain="true" :nowrap="true" :author="appearNote.user" :nyaize="'respect'" :class="$style.collapsedRenoteTargetText" @click="renoteCollapsed = false"/>
+ <Mfm :text="getNoteSummary(appearNote)" :isBlock="true" :plain="true" :nowrap="true" :author="appearNote.user" :nyaize="'respect'" :class="$style.collapsedRenoteTargetText" @click="renoteCollapsed = false; inReplyToCollapsed = false"/>
</div>
<article v-else :class="$style.article" @contextmenu.stop="onContextmenu">
<div style="display: flex; padding-bottom: 10px;">
@@ -57,7 +65,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="[{ [$style.clickToOpen]: defaultStore.state.clickToOpen }]" @click.stop="defaultStore.state.clickToOpen ? noteclick(appearNote.id) : undefined">
<div style="container-type: inline-size;">
<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'"/>
+ <Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :isBlock="true" :author="appearNote.user" :nyaize="'respect'"/>
<MkCwButton v-model="showContent" :text="appearNote.text" :renote="appearNote.renote" :files="appearNote.files" :poll="appearNote.poll" style="margin: 4px 0;" @click.stop/>
</p>
<div v-show="appearNote.cw == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]">
@@ -73,12 +81,13 @@ SPDX-License-Identifier: AGPL-3.0-only
:enableEmojiMenu="true"
:enableEmojiMenuReaction="true"
:isAnim="allowAnim"
+ :isBlock="true"
/>
<div v-if="translating || translation" :class="$style.translation">
<MkLoading v-if="translating" mini/>
<div v-else-if="translation">
<b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b>
- <Mfm :text="translation.text" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/>
+ <Mfm :text="translation.text" :isBlock="true" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/>
</div>
</div>
<MkButton v-if="!allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton>
@@ -88,7 +97,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkMediaList :mediaList="appearNote.files" @click.stop/>
</div>
<MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll" @click.stop/>
- <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview" @click.stop/>
+ <div v-if="isEnabledUrlPreview">
+ <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview" @click.stop/>
+ </div>
<div v-if="appearNote.renote" :class="$style.quote"><SkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
<button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click.stop @click="collapsed = false">
<span :class="$style.collapsedLabel">{{ i18n.ts.showMore }}</span>
@@ -99,15 +110,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="ph-television ph-bold ph-lg"></i> {{ appearNote.channel.name }}</MkA>
</div>
- <MkReactionsViewer :note="appearNote" :maxNumber="16" @click.stop @mockUpdateMyReaction="emitUpdReaction">
+ <MkReactionsViewer v-if="appearNote.reactionAcceptance !== 'likeOnly'" :note="appearNote" :maxNumber="16" @click.stop @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.stop @click="reply()">
<i class="ph-arrow-u-up-left ph-bold ph-lg"></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"
@@ -119,7 +130,7 @@ SPDX-License-Identifier: AGPL-3.0-only
@mousedown="renoted ? undoRenote(appearNote) : boostVisibility()"
>
<i class="ph-rocket-launch ph-bold ph-lg"></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="ph-prohibit ph-bold ph-lg"></i>
@@ -137,12 +148,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<button v-if="appearNote.myReaction == null && appearNote.reactionAcceptance !== 'likeOnly'" ref="likeButton" :class="$style.footerButton" class="_button" @click.stop @click="like()">
<i class="ph-heart ph-bold ph-lg"></i>
</button>
- <button v-if="appearNote.myReaction == null" ref="reactButton" :class="$style.footerButton" class="_button" @mousedown="react()">
- <i v-if="appearNote.reactionAcceptance === 'likeOnly'" class="ph-heart ph-bold ph-lg"></i>
+ <button ref="reactButton" :class="$style.footerButton" class="_button" @click="toggleReact()" @click.stop>
+ <i v-if="appearNote.reactionAcceptance === 'likeOnly' && appearNote.myReaction != null" class="ph-heart ph-bold ph-lg" style="color: var(--eventReactionHeart);"></i>
+ <i v-else-if="appearNote.myReaction != null" class="ph-minus ph-bold ph-lg" style="color: var(--accent);"></i>
+ <i v-else-if="appearNote.reactionAcceptance === 'likeOnly'" class="ph-heart ph-bold ph-lg"></i>
<i v-else class="ph-smiley ph-bold ph-lg"></i>
- </button>
- <button v-if="appearNote.myReaction != null" ref="reactButton" :class="$style.footerButton" class="_button" @click.stop @click="undoReact(appearNote)">
- <i class="ph-minus ph-bold ph-lg"></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="ph-paperclip ph-bold ph-lg"></i>
@@ -186,6 +197,7 @@ import SkNoteSub from '@/components/SkNoteSub.vue';
import SkNoteHeader from '@/components/SkNoteHeader.vue';
import SkNoteSimple from '@/components/SkNoteSimple.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';
@@ -196,8 +208,9 @@ 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 { 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';
@@ -218,6 +231,7 @@ import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
import { shouldCollapsed } from '@/scripts/collapsed.js';
import { useRouter } from '@/router/supplier.js';
import { boostMenuItems, type Visibility } from '@/scripts/boost-quote.js';
+import { isEnabledUrlPreview } from '@/instance.js';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
@@ -271,6 +285,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 &&
@@ -308,6 +323,7 @@ const renoteCollapsed = ref(
(appearNote.value.myReaction != null)
)
);
+const inReplyToCollapsed = ref(defaultStore.state.collapseNotesRepliedTo);
const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null);
const animated = computed(() => parsed.value ? checkAnimationFromMfm(parsed.value) : null);
const allowAnim = ref(defaultStore.state.advancedMfm && defaultStore.state.animatedMfm ? true : false);
@@ -329,10 +345,12 @@ function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string
return false;
}
+let renoting = false;
+
const keymap = {
'r': () => reply(true),
'e|a|plus': () => react(true),
- 'q': () => renote(appearNote.value.visibility),
+ '(q)': () => { if (canRenote.value && !renoted.value && !renoting) renote(defaultStore.state.visibilityOnBoost); },
'up|k|shift+tab': focusBefore,
'down|j|tab': focusAfter,
'esc': blur,
@@ -407,9 +425,33 @@ if (!props.mock) {
renoted.value = res.length > 0;
});
}
+
+ 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 boostVisibility() {
+ if (renoting) return;
+
if (!defaultStore.state.showVisibilitySelectorOnBoost) {
renote(defaultStore.state.visibilityOnBoost);
} else {
@@ -421,6 +463,8 @@ function renote(visibility: Visibility, localOnly: boolean = false) {
pleaseLogin();
showMovedDialog();
+ renoting = true;
+
if (appearNote.value.channel) {
const el = renoteButton.value as HTMLElement | null | undefined;
if (el) {
@@ -437,7 +481,7 @@ function renote(visibility: Visibility, localOnly: boolean = false) {
}).then(() => {
os.toast(i18n.ts.renoted);
renoted.value = true;
- });
+ }).finally(() => { renoting = false; });
}
} else if (!appearNote.value.channel || appearNote.value.channel.allowRenoteToExternal) {
const el = renoteButton.value as HTMLElement | null | undefined;
@@ -456,7 +500,7 @@ function renote(visibility: Visibility, localOnly: boolean = false) {
}).then(() => {
os.toast(i18n.ts.renoted);
renoted.value = true;
- });
+ }).finally(() => { renoting = false; });
}
}
}
@@ -627,6 +671,14 @@ function undoRenote(note) : void {
}
}
+function toggleReact() {
+ if (appearNote.value.myReaction == null) {
+ react();
+ } else {
+ undoReact(appearNote.value);
+ }
+}
+
function onContextmenu(ev: MouseEvent): void {
if (props.mock) {
return;
@@ -931,7 +983,7 @@ function emitUpdReaction(emoji: string, delta: number) {
margin-right: 4px;
}
-.collapsedRenoteTarget {
+.collapsedRenoteTarget, .collapsedInReplyTo {
display: flex;
align-items: center;
line-height: 28px;
@@ -939,7 +991,11 @@ function emitUpdReaction(emoji: string, delta: number) {
padding: 8px 38px 24px;
}
-.collapsedRenoteTargetAvatar {
+.collapsedInReplyTo {
+ padding: 28px 44px 0;
+}
+
+.collapsedRenoteTargetAvatar, .collapsedInReplyToAvatar {
flex-shrink: 0;
display: inline-block;
width: 28px;
@@ -947,7 +1003,7 @@ function emitUpdReaction(emoji: string, delta: number) {
margin: 0 8px 0 0;
}
-.collapsedRenoteTargetText {
+.collapsedRenoteTargetText, .collapsedInReplyToText {
overflow: hidden;
flex-shrink: 1;
text-overflow: ellipsis;
@@ -961,6 +1017,15 @@ function emitUpdReaction(emoji: string, delta: number) {
}
}
+.collapsedInReplyToLine {
+ position: absolute;
+ left: calc(32px + .5 * var(--avatar));
+ // using solid instead of dotted, stylelistic choice
+ border-left: var(--thread-width) solid var(--thread);
+ top: calc(28px + 28px); // 28px of .root padding, plus 28px of avatar height (see SkNote)
+ height: 28px;
+}
+
.article {
position: relative;
padding: 28px 32px;
@@ -1139,6 +1204,14 @@ function emitUpdReaction(emoji: string, delta: number) {
padding: 8px 26px 24px;
}
+ .collapsedInReplyTo {
+ padding: 28px 35px 0;
+ }
+
+ .collapsedInReplyToLine {
+ left: calc(26px + .5 * var(--avatar));
+ }
+
.article {
padding: 24px 26px;
}
@@ -1156,6 +1229,10 @@ function emitUpdReaction(emoji: string, delta: number) {
.footer {
margin-bottom: -8px;
}
+
+ .collapsedInReplyToLine {
+ left: calc(25px + .5 * var(--avatar));
+ }
}
@container (max-width: 500px) {
@@ -1186,6 +1263,15 @@ function emitUpdReaction(emoji: string, delta: number) {
margin-top: 4px;
}
+ .collapsedInReplyTo {
+ padding: 22px 33px 0;
+ }
+
+ .collapsedInReplyToLine {
+ left: calc(24px + .5 * var(--avatar));
+ top: calc(22px + 28px); // 22px of .root padding, plus 28px of avatar height
+ }
+
.article {
padding: 22px 24px;
}
@@ -1253,10 +1339,9 @@ 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%;
}
.clickToOpen {
diff --git a/packages/frontend/src/components/SkNoteDetailed.vue b/packages/frontend/src/components/SkNoteDetailed.vue
index ced7e7a176..7a23d0aa73 100644
--- a/packages/frontend/src/components/SkNoteDetailed.vue
+++ b/packages/frontend/src/components/SkNoteDetailed.vue
@@ -77,8 +77,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</header>
<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"/>
+ <Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :isBlock="true" :author="appearNote.user" :nyaize="'respect'"/>
+ <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>
@@ -92,13 +92,14 @@ SPDX-License-Identifier: AGPL-3.0-only
:enableEmojiMenu="true"
:enableEmojiMenuReaction="true"
:isAnim="allowAnim"
+ :isBlock="true"
/>
<a v-if="appearNote.renote != null" :class="$style.rn">RN:</a>
<div v-if="translating || translation" :class="$style.translation">
<MkLoading v-if="translating" mini/>
<div v-else-if="translation">
<b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b>
- <Mfm :text="translation.text" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/>
+ <Mfm :text="translation.text" :isBlock="true" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/>
</div>
</div>
<MkButton v-if="!allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton>
@@ -107,7 +108,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"><SkNoteSimple :note="appearNote.renote" :class="$style.quoteNote" :expandAllCws="props.expandAllCws"/></div>
</div>
<MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ph-television ph-bold ph-lg"></i> {{ appearNote.channel.name }}</MkA>
@@ -120,11 +123,11 @@ 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"/>
<footer :class="$style.footer">
<button class="_button" :class="$style.noteFooterButton" @click="reply()">
<i class="ph-arrow-u-up-left ph-bold ph-lg"></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"
@@ -135,7 +138,7 @@ SPDX-License-Identifier: AGPL-3.0-only
@mousedown="renoted ? undoRenote() : boostVisibility()"
>
<i class="ph-rocket-launch ph-bold ph-lg"></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="ph-prohibit ph-bold ph-lg"></i>
@@ -152,12 +155,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<button v-if="appearNote.myReaction == null && appearNote.reactionAcceptance !== 'likeOnly'" ref="likeButton" :class="$style.noteFooterButton" class="_button" @mousedown="like()">
<i class="ph-heart ph-bold ph-lg"></i>
</button>
- <button v-if="appearNote.myReaction == null" ref="reactButton" :class="$style.noteFooterButton" class="_button" @mousedown="react()">
- <i v-if="appearNote.reactionAcceptance === 'likeOnly'" class="ph-heart ph-bold ph-lg"></i>
+ <button ref="reactButton" :class="$style.noteFooterButton" class="_button" @click="toggleReact()">
+ <i v-if="appearNote.reactionAcceptance === 'likeOnly' && appearNote.myReaction != null" class="ph-heart ph-bold ph-lg" style="color: var(--eventReactionHeart);"></i>
+ <i v-else-if="appearNote.myReaction != null" class="ph-minus ph-bold ph-lg" style="color: var(--accent);"></i>
+ <i v-else-if="appearNote.reactionAcceptance === 'likeOnly'" class="ph-heart ph-bold ph-lg"></i>
<i v-else class="ph-smiley ph-bold ph-lg"></i>
- </button>
- <button v-if="appearNote.myReaction != null" ref="reactButton" class="_button" :class="[$style.noteFooterButton, $style.reacted]" @click="undoReact(appearNote)">
- <i class="ph-minus ph-bold ph-lg"></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="ph-paperclip ph-bold ph-lg"></i>
@@ -234,6 +237,7 @@ import * as Misskey from 'misskey-js';
import SkNoteSub from '@/components/SkNoteSub.vue';
import SkNoteSimple from '@/components/SkNoteSimple.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';
@@ -243,9 +247,10 @@ import SkInstanceTicker from '@/components/SkInstanceTicker.vue';
import { pleaseLogin } from '@/scripts/please-login.js';
import { checkWordMute } from '@/scripts/check-word-mute.js';
import { userPage } from '@/filters/user.js';
+import number from '@/filters/number.js';
import { notePage } from '@/filters/note.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';
@@ -266,11 +271,15 @@ import MkPagination, { type Paging } from '@/components/MkPagination.vue';
import MkReactionIcon from '@/components/MkReactionIcon.vue';
import MkButton from '@/components/MkButton.vue';
import { boostMenuItems, type Visibility } from '@/scripts/boost-quote.js';
+import { isEnabledUrlPreview } from '@/instance.js';
-const props = defineProps<{
+const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
expandAllCws?: boolean;
-}>();
+ initialTab: string;
+}>(), {
+ initialTab: 'replies',
+});
const inChannel = inject('inChannel', null);
@@ -297,7 +306,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
);
@@ -345,10 +356,12 @@ if ($i) {
});
}
+let renoting = false;
+
const keymap = {
'r': () => reply(true),
'e|a|plus': () => react(true),
- 'q': () => renote(appearNote.value.visibility),
+ '(q)': () => { if (canRenote.value && !renoted.value && !renoting) renote(defaultStore.state.visibilityOnBoost); },
'esc': blur,
'm|o': () => showMenu(true),
's': () => showContent.value !== showContent.value,
@@ -361,7 +374,7 @@ provide('react', (reaction: string) => {
});
});
-const tab = ref('replies');
+const tab = ref(props.initialTab);
const reactionTabType = ref<string | null>(null);
const renotesPagination = computed<Paging>(() => ({
@@ -440,6 +453,8 @@ useTooltip(quoteButton, async (showing) => {
});
function boostVisibility() {
+ if (renoting) return;
+
if (!defaultStore.state.showVisibilitySelectorOnBoost) {
renote(defaultStore.state.visibilityOnBoost);
} else {
@@ -447,10 +462,34 @@ function boostVisibility() {
}
}
+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(visibility: Visibility, localOnly: boolean = false) {
pleaseLogin();
showMovedDialog();
+ renoting = true;
+
if (appearNote.value.channel) {
const el = renoteButton.value as HTMLElement | null | undefined;
if (el) {
@@ -466,7 +505,7 @@ function renote(visibility: Visibility, localOnly: boolean = false) {
}).then(() => {
os.toast(i18n.ts.renoted);
renoted.value = true;
- });
+ }).finally(() => { renoting = false; });
} else if (!appearNote.value.channel || appearNote.value.channel.allowRenoteToExternal) {
const el = renoteButton.value as HTMLElement | null | undefined;
if (el) {
@@ -483,7 +522,7 @@ function renote(visibility: Visibility, localOnly: boolean = false) {
}).then(() => {
os.toast(i18n.ts.renoted);
renoted.value = true;
- });
+ }).finally(() => { renoting = false; });
}
}
@@ -603,11 +642,11 @@ function like(): 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,
});
}
@@ -628,6 +667,14 @@ function undoRenote() : void {
}
}
+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/SkNoteHeader.vue b/packages/frontend/src/components/SkNoteHeader.vue
index 7dc4c8f019..2f177815ee 100644
--- a/packages/frontend/src/components/SkNoteHeader.vue
+++ b/packages/frontend/src/components/SkNoteHeader.vue
@@ -1,5 +1,5 @@
<!--
-SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-FileCopyrightText: marie and other Sharkey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
diff --git a/packages/frontend/src/components/SkNoteSimple.vue b/packages/frontend/src/components/SkNoteSimple.vue
index 533aa60961..b31e337a99 100644
--- a/packages/frontend/src/components/SkNoteSimple.vue
+++ b/packages/frontend/src/components/SkNoteSimple.vue
@@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkNoteHeader :class="$style.header" :classic="true" :note="note" :mini="true"/>
<div>
<p v-if="note.cw != null" :class="$style.cw">
- <Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/>
+ <Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :isBlock="true" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/>
<MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll" @click.stop/>
</p>
<div v-show="note.cw == null || showContent">
diff --git a/packages/frontend/src/components/SkNoteSub.vue b/packages/frontend/src/components/SkNoteSub.vue
index 1cffd8dd66..dc1d5b10b2 100644
--- a/packages/frontend/src/components/SkNoteSub.vue
+++ b/packages/frontend/src/components/SkNoteSub.vue
@@ -1,5 +1,5 @@
<!--
-SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-FileCopyrightText: marie and other Sharkey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
@@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<SkNoteHeader :class="$style.header" :note="note" :classic="true" :mini="true"/>
<div :class="$style.content">
<p v-if="note.cw != null" :class="$style.cw">
- <Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'"/>
+ <Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :isBlock="true" :author="note.user" :nyaize="'respect'"/>
<MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll"/>
</p>
<div v-show="note.cw == null || showContent">
diff --git a/packages/frontend/src/components/SkOldNoteWindow.vue b/packages/frontend/src/components/SkOldNoteWindow.vue
index bed44bbb08..3810b62366 100644
--- a/packages/frontend/src/components/SkOldNoteWindow.vue
+++ b/packages/frontend/src/components/SkOldNoteWindow.vue
@@ -1,3 +1,8 @@
+<!--
+SPDX-FileCopyrightText: marie and other Sharkey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
<template>
<MkWindow ref="window" :initialWidth="500" :initialHeight="300" :canResize="true" @closed="emit('closed')">
<template #header>
@@ -29,19 +34,19 @@
</header>
<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="'account'"/>
+ <Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :isBlock="true" :author="appearNote.user" :nyaize="'account'"/>
<MkCwButton v-model="showContent" :text="appearNote.text" :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>
<MkA v-if="appearNote.replyId" :class="$style.noteReplyTarget" :to="`/notes/${appearNote.replyId}`"><i class="ph-arrow-bend-left-up ph-bold ph-lg"></i></MkA>
- <Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :nyaize="'account'" :emojiUrls="appearNote.emojis"/>
+ <Mfm v-if="appearNote.text" :text="appearNote.text" :isBlock="true" :author="appearNote.user" :nyaize="'account'" :emojiUrls="appearNote.emojis"/>
<a v-if="appearNote.renote != null" :class="$style.rn">RN:</a>
<div v-if="translating || translation" :class="$style.translation">
<MkLoading v-if="translating" mini/>
<div v-else>
<b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: </b>
- <Mfm :text="translation.text" :author="appearNote.user" :nyaize="'account'" :emojiUrls="appearNote.emojis"/>
+ <Mfm :text="translation.text" :isBlock="true" :author="appearNote.user" :nyaize="'account'" :emojiUrls="appearNote.emojis"/>
</div>
</div>
<div v-if="appearNote.files.length > 0">
diff --git a/packages/frontend/src/components/SkOneko.vue b/packages/frontend/src/components/SkOneko.vue
index fbf50067a9..24bb392335 100644
--- a/packages/frontend/src/components/SkOneko.vue
+++ b/packages/frontend/src/components/SkOneko.vue
@@ -1,3 +1,8 @@
+<!--
+SPDX-FileCopyrightText: kopper and other Sharkey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
<template>
<div ref="nekoEl" :class="$style.oneko" aria-hidden="true"></div>
</template>
@@ -235,6 +240,6 @@ onMounted(init);
pointer-events: none;
image-rendering: pixelated;
z-index: 2147483647;
- background-image: url(/client-assets/oneko.gif);
+ background-image: var(--oneko-image, url(/client-assets/oneko.gif));
}
</style>
diff --git a/packages/frontend/src/components/SkSearchResultWindow.vue b/packages/frontend/src/components/SkSearchResultWindow.vue
index 5a0412685a..474b0d54e7 100644
--- a/packages/frontend/src/components/SkSearchResultWindow.vue
+++ b/packages/frontend/src/components/SkSearchResultWindow.vue
@@ -1,3 +1,8 @@
+<!--
+SPDX-FileCopyrightText: marie and other Sharkey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
<template>
<MkWindow ref="window" :initialWidth="600" :initialHeight="450" :canResize="true" @closed="emit('closed')">
<template #header>
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 b3c58cf235..5b67c3f7bd 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" @click.stop>
+<a ref="el" :href="to" :class="active ? activeClass : null" @click.prevent="nav" @contextmenu.prevent.stop="onContextmenu" @click.stop>
<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 f13a161ae8..c01211443d 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="ph-info ph-bold ph-lg"></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 f8b5fcfedc..1bc33585f2 100644
--- a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts
+++ b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts
@@ -3,9 +3,10 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { VNode, h, defineAsyncComponent, SetupContext } from 'vue';
+import { VNode, h, defineAsyncComponent, SetupContext, provide } from 'vue';
import * as mfm from '@transfem-org/sfm-js';
import * as Misskey from 'misskey-js';
+import CkFollowMouse from '../CkFollowMouse.vue';
import MkUrl from '@/components/global/MkUrl.vue';
import MkTime from '@/components/global/MkTime.vue';
import MkLink from '@/components/MkLink.vue';
@@ -16,7 +17,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';
@@ -44,6 +45,8 @@ type MfmProps = {
enableEmojiMenu?: boolean;
enableEmojiMenuReaction?: boolean;
isAnim?: boolean;
+ linkNavigationBehavior?: MkABehavior;
+ isBlock?: boolean;
};
type MfmEvents = {
@@ -52,6 +55,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 ? props.author.speakAsCat : false : false : false;
@@ -73,6 +78,8 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven
return c.match(/^[0-9a-f]{3,6}$/i) ? c : null;
};
+ const isBlock = props.isBlock ?? false;
+
const MkFormula = defineAsyncComponent(() => import('@/components/MkFormula.vue'));
/**
@@ -227,11 +234,48 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven
}
return h(MkSparkle, {}, genEl(token.children, scale));
}
+ case 'fade': {
+ if (!useAnim) {
+ style = '';
+ break;
+ }
+
+ const direction = token.props.args.out
+ ? 'alternate-reverse'
+ : 'alternate';
+ const speed = validTime(token.props.args.speed) ?? '1.5s';
+ const delay = validTime(token.props.args.delay) ?? '0s';
+ const loop = safeParseFloat(token.props.args.loop) ?? 'infinite';
+ style = `animation: mfm-fade ${speed} ${delay} linear ${loop}; animation-direction: ${direction};`;
+ break;
+ }
case 'rotate': {
const degrees = safeParseFloat(token.props.args.deg) ?? 90;
style = `transform: rotate(${degrees}deg); transform-origin: center center;`;
break;
}
+ case 'followmouse': {
+ // Make sure advanced MFM is on and that reduced motion is off
+ if (!useAnim) {
+ style = '';
+ break;
+ }
+
+ let x = (!!token.props.args.x);
+ let y = (!!token.props.args.y);
+
+ if (!x && !y) {
+ x = true;
+ y = true;
+ }
+
+ return h(CkFollowMouse, {
+ x: x,
+ y: y,
+ speed: validTime(token.props.args.speed) ?? '0.1s',
+ rotateByVelocity: !!token.props.args.rotateByVelocity,
+ }, genEl(token.children, scale));
+ }
case 'position': {
if (!defaultStore.state.advancedMfm) break;
const x = safeParseFloat(token.props.args.x) ?? 0;
@@ -239,6 +283,22 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven
style = `transform: translateX(${x}em) translateY(${y}em);`;
break;
}
+ case 'crop': {
+ const top = Number.parseFloat(
+ (token.props.args.top ?? '0').toString(),
+ );
+ const right = Number.parseFloat(
+ (token.props.args.right ?? '0').toString(),
+ );
+ const bottom = Number.parseFloat(
+ (token.props.args.bottom ?? '0').toString(),
+ );
+ const left = Number.parseFloat(
+ (token.props.args.left ?? '0').toString(),
+ );
+ style = `clip-path: inset(${top}% ${right}% ${bottom}% ${left}%);`;
+ break;
+ }
case 'scale': {
if (!defaultStore.state.advancedMfm) {
style = '';
@@ -337,65 +397,65 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven
case 'center': {
return [h('div', {
style: 'text-align:center;',
- }, genEl(token.children, scale))];
+ }, h('bdi', genEl(token.children, scale)))];
}
case 'url': {
- return [h(MkUrl, {
+ return [h('bdi', h(MkUrl, {
key: Math.random(),
url: token.props.url,
rel: 'nofollow noopener',
- })];
+ }))];
}
case 'link': {
- return [h(MkLink, {
+ return [h('bdi', h(MkLink, {
key: Math.random(),
url: token.props.url,
rel: 'nofollow noopener',
- }, genEl(token.children, scale, true))];
+ }, genEl(token.children, scale, true)))];
}
case 'mention': {
- return [h(MkMention, {
+ return [h('bdi', h(MkMention, {
key: Math.random(),
host: (token.props.host == null && props.author && props.author.host != null ? props.author.host : token.props.host) ?? host,
username: token.props.username,
- })];
+ }))];
}
case 'hashtag': {
- return [h(MkA, {
+ return [h('bdi', h(MkA, {
key: Math.random(),
to: isNote ? `/tags/${encodeURIComponent(token.props.hashtag)}` : `/user-tags/${encodeURIComponent(token.props.hashtag)}`,
style: 'color:var(--hashtag);',
- }, `#${token.props.hashtag}`)];
+ }, `#${token.props.hashtag}`))];
}
case 'blockCode': {
- return [h(MkCode, {
+ return [h('bdi', { class: 'block' }, h(MkCode, {
key: Math.random(),
code: token.props.code,
lang: token.props.lang ?? undefined,
- })];
+ }))];
}
case 'inlineCode': {
- return [h(MkCodeInline, {
+ return [h('bdi', h(MkCodeInline, {
key: Math.random(),
code: token.props.code,
- })];
+ }))];
}
case 'quote': {
if (!props.nowrap) {
- return [h('div', {
+ return [h('bdi', { class: 'block' }, h('div', {
style: QUOTE_STYLE,
- }, genEl(token.children, scale, true))];
+ }, h('bdi', genEl(token.children, scale, true))))];
} else {
return [h('span', {
style: QUOTE_STYLE,
- }, genEl(token.children, scale, true))];
+ }, h('bdi', genEl(token.children, scale, true)))];
}
}
@@ -439,17 +499,17 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven
}
case 'mathInline': {
- return [h(MkFormula, {
+ return [h('bdi', h(MkFormula, {
formula: token.props.formula,
block: false,
- })];
+ }))];
}
case 'mathBlock': {
- return [h(MkFormula, {
+ return [h('bdi', { class: 'block' }, h(MkFormula, {
formula: token.props.formula,
block: true,
- })];
+ }))];
}
case 'search': {
@@ -460,7 +520,7 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven
}
case 'plain': {
- return [h('span', genEl(token.children, scale, true))];
+ return [h('bdi', h('span', genEl(token.children, scale, true)))];
}
default: {
@@ -472,8 +532,8 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven
}
}).flat(Infinity) as (VNode | string)[];
- return h('span', {
+ return h('bdi', { ...( isBlock ? { class: 'block' } : {}) }, h('span', {
// https://codeday.me/jp/qa/20190424/690106.html
style: props.nowrap ? 'white-space: pre; word-wrap: normal; overflow: hidden; text-overflow: ellipsis;' : 'white-space: pre-wrap;',
- }, genEl(rootAst, props.rootScale ?? 1));
+ }, genEl(rootAst, props.rootScale ?? 1)));
}
diff --git a/packages/frontend/src/components/global/MkPageHeader.stories.impl.ts b/packages/frontend/src/components/global/MkPageHeader.stories.impl.ts
index d4327e1463..25f5051648 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 53bb5472dc..7d13fb9279 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 b810840b69..19888ae146 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="() => {}"
@click.stop
>
@@ -31,11 +32,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,
});
@@ -45,12 +49,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..7f80f0c455
--- /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="ph ph-dice-5 ph-bold ph-lg"></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 6a9415e137..5f88acb11d 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">
- <Mfm :text="block.text ?? ''" :isNote="false"/>
- <MkUrlPreview v-for="url in urls" :key="url" :url="url"/>
+<div class="_gaps" :class="$style.textRoot">
+ <Mfm :text="block.text ?? ''" :isBlock="true" :isNote="false"/>
+ <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 '@transfem-org/sfm-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>