summaryrefslogtreecommitdiff
path: root/packages/frontend/src/components
diff options
context:
space:
mode:
authorJulia <julia@insertdomain.name>2025-06-19 21:35:18 +0000
committerJulia <julia@insertdomain.name>2025-06-19 21:35:18 +0000
commita77c32b17da63d3932b219f74152cce023a30f4a (patch)
treed2a05796e942c8f250bbd01369eab0cbe5a14531 /packages/frontend/src/components
parentmerge: release 2025.4.2 (!1051) (diff)
parentMerge branch 'develop' into release/2025.4.3 (diff)
downloadsharkey-a77c32b17da63d3932b219f74152cce023a30f4a.tar.gz
sharkey-a77c32b17da63d3932b219f74152cce023a30f4a.tar.bz2
sharkey-a77c32b17da63d3932b219f74152cce023a30f4a.zip
merge: prepare release 2025.4.3 (!1125)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1125 Approved-by: Marie <github@yuugi.dev> Approved-by: Julia <julia@insertdomain.name>
Diffstat (limited to 'packages/frontend/src/components')
-rw-r--r--packages/frontend/src/components/MkAbuseReport.vue65
-rw-r--r--packages/frontend/src/components/MkCaptcha.vue2
-rw-r--r--packages/frontend/src/components/MkCode.core.vue2
-rw-r--r--packages/frontend/src/components/MkDateSeparatedList.vue5
-rw-r--r--packages/frontend/src/components/MkDonation.vue2
-rw-r--r--packages/frontend/src/components/MkFolder.vue6
-rw-r--r--packages/frontend/src/components/MkNote.vue72
-rw-r--r--packages/frontend/src/components/MkNoteDetailed.vue80
-rw-r--r--packages/frontend/src/components/MkNoteSub.vue65
-rw-r--r--packages/frontend/src/components/MkNotes.vue11
-rw-r--r--packages/frontend/src/components/MkNotifications.vue7
-rw-r--r--packages/frontend/src/components/MkPageWindow.vue2
-rw-r--r--packages/frontend/src/components/MkPagination.vue2
-rw-r--r--packages/frontend/src/components/MkPostForm.vue9
-rw-r--r--packages/frontend/src/components/MkReactionsViewer.vue12
-rw-r--r--packages/frontend/src/components/MkSelect.vue32
-rw-r--r--packages/frontend/src/components/MkSignupDialog.form.vue2
-rw-r--r--packages/frontend/src/components/MkSubNoteContent.vue8
-rw-r--r--packages/frontend/src/components/MkTimeline.vue19
-rw-r--r--packages/frontend/src/components/MkUrlPreview.vue136
-rw-r--r--packages/frontend/src/components/SkApprovalUser.vue2
-rw-r--r--packages/frontend/src/components/SkBadgeStrip.vue84
-rw-r--r--packages/frontend/src/components/SkDateSeparatedList.vue55
-rw-r--r--packages/frontend/src/components/SkFollowingRecentNotes.vue1
-rw-r--r--packages/frontend/src/components/SkMfmWindow.vue13
-rw-r--r--packages/frontend/src/components/SkMutedNote.vue2
-rw-r--r--packages/frontend/src/components/SkNote.vue57
-rw-r--r--packages/frontend/src/components/SkNoteDetailed.vue55
-rw-r--r--packages/frontend/src/components/SkNoteSub.vue55
-rw-r--r--packages/frontend/src/components/SkNoteTranslation.vue1
-rw-r--r--packages/frontend/src/components/SkOldNoteWindow.vue43
-rw-r--r--packages/frontend/src/components/SkTransitionGroup.vue43
-rw-r--r--packages/frontend/src/components/SkUrlPreviewGroup.vue348
-rw-r--r--packages/frontend/src/components/global/MkMfm.ts2
-rw-r--r--packages/frontend/src/components/global/MkStickyContainer.vue12
-rw-r--r--packages/frontend/src/components/global/PageWithHeader.vue9
-rw-r--r--packages/frontend/src/components/global/StackingRouterView.vue15
-rw-r--r--packages/frontend/src/components/page/page.note.vue20
-rw-r--r--packages/frontend/src/components/page/page.text.vue15
-rw-r--r--packages/frontend/src/components/page/page.vue2
40 files changed, 965 insertions, 408 deletions
diff --git a/packages/frontend/src/components/MkAbuseReport.vue b/packages/frontend/src/components/MkAbuseReport.vue
index c52fdb898e..6025bc44f0 100644
--- a/packages/frontend/src/components/MkAbuseReport.vue
+++ b/packages/frontend/src/components/MkAbuseReport.vue
@@ -33,10 +33,28 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkFolder :withSpacer="false">
<template #icon><MkAvatar :user="report.targetUser" style="width: 18px; height: 18px;"/></template>
<template #label>{{ i18n.ts.target }}: <MkAcct :user="report.targetUser"/></template>
- <template #suffix>#{{ report.targetUserId.toUpperCase() }}</template>
+ <template #suffix>{{ i18n.ts.id }}# {{ report.targetUserId }}</template>
- <div style="height: 300px; --MI-stickyTop: 0; --MI-stickyBottom: 0;">
- <RouterView :router="targetRouter"/>
+ <div style="--MI-stickyTop: 0; --MI-stickyBottom: 0;">
+ <admin-user :userId="report.targetUserId" :userHint="report.targetUser"></admin-user>
+ </div>
+ </MkFolder>
+
+ <MkFolder v-if="report.targetInstance" :withSpacer="false">
+ <template #icon>
+ <img
+ v-if="targetInstanceIcon"
+ :src="targetInstanceIcon"
+ :alt="i18n.tsx.instanceIconAlt({ name: report.targetInstance.name || report.targetInstance.host })"
+ :class="$style.instanceIcon"
+ class="icon"
+ />
+ </template>
+ <template #label>{{ i18n.ts.instance }}: {{ report.targetInstance.name || report.targetInstance.host }}</template>
+ <template #suffix>{{ i18n.ts.id }}# {{ report.targetInstance.id }}</template>
+
+ <div style="--MI-stickyTop: 0; --MI-stickyBottom: 0;">
+ <instance-info :host="report.targetInstance.host" :instanceHint="report.targetInstance" :metaHint="metaHint"></instance-info>
</div>
</MkFolder>
@@ -44,23 +62,24 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #icon><i class="ti ti-message-2"></i></template>
<template #label>{{ i18n.ts.details }}</template>
<div class="_gaps_s">
- <Mfm :text="report.comment" :isBlock="true" :linkNavigationBehavior="'window'"/>
+ <Mfm :text="report.comment" :parsedNodes="parsedComment" :isBlock="true" :linkNavigationBehavior="'window'" :author="report.reporter" :nyaize="false" :isAnim="false"/>
+ <SkUrlPreviewGroup :sourceNodes="parsedComment" :compact="false" :detail="false" :showAsQuote="true"/>
</div>
</MkFolder>
<MkFolder :withSpacer="false">
<template #icon><MkAvatar :user="report.reporter" style="width: 18px; height: 18px;"/></template>
<template #label>{{ i18n.ts.reporter }}: <MkAcct :user="report.reporter"/></template>
- <template #suffix>#{{ report.reporterId.toUpperCase() }}</template>
+ <template #suffix>{{ i18n.ts.id }}# {{ report.reporterId }}</template>
- <div style="height: 300px; --MI-stickyTop: 0; --MI-stickyBottom: 0;">
- <RouterView :router="reporterRouter"/>
+ <div style="--MI-stickyTop: 0; --MI-stickyBottom: 0;">
+ <admin-user :userId="report.reporterId" :userHint="report.reporter"></admin-user>
</div>
</MkFolder>
<MkFolder :defaultOpen="false">
<template #icon><i class="ti ti-message-2"></i></template>
- <template #label>{{ i18n.ts.moderationNote }}</template>
+ <template #label>{{ i18n.ts.staffNotes }}</template>
<template #suffix>{{ moderationNote.length > 0 ? '...' : i18n.ts.none }}</template>
<div class="_gaps_s">
<MkTextarea v-model="moderationNote" manualSave>
@@ -78,8 +97,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
-import { provide, ref, watch } from 'vue';
+import { computed, provide, ref, watch } from 'vue';
import * as Misskey from 'misskey-js';
+import * as mfm from 'mfm-js';
import MkButton from '@/components/MkButton.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkKeyValue from '@/components/MkKeyValue.vue';
@@ -91,19 +111,38 @@ import RouterView from '@/components/global/RouterView.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
import { createRouter } from '@/router.js';
+import { getProxiedImageUrlNullable } from '@/utility/media-proxy';
+import InstanceInfo from '@/pages/instance-info.vue';
+import { iAmAdmin } from '@/i';
+import { misskeyApi } from '@/utility/misskey-api';
+import AdminUser from '@/pages/admin-user.vue';
+import SkUrlPreviewGroup from '@/components/SkUrlPreviewGroup.vue';
-const props = defineProps<{
+const props = withDefaults(defineProps<{
report: Misskey.entities.AdminAbuseUserReportsResponse[number];
-}>();
+ metaHint?: Misskey.entities.AdminMetaResponse | undefined;
+}>(), {
+ metaHint: undefined,
+});
const emit = defineEmits<{
(ev: 'resolved', reportId: string): void;
}>();
+/*
const targetRouter = createRouter(`/admin/user/${props.report.targetUserId}`);
targetRouter.init();
const reporterRouter = createRouter(`/admin/user/${props.report.reporterId}`);
reporterRouter.init();
+*/
+
+const parsedComment = computed(() => mfm.parse(props.report.comment));
+
+const targetInstanceIcon = computed(() => props.report.targetInstance?.faviconUrl
+ ? getProxiedImageUrlNullable(props.report.targetInstance.faviconUrl, 'preview')
+ : props.report.targetInstance?.iconUrl
+ ? getProxiedImageUrlNullable(props.report.targetInstance.iconUrl, 'preview')
+ : null);
const moderationNote = ref(props.report.moderationNote ?? '');
@@ -150,4 +189,8 @@ function showMenu(ev: MouseEvent) {
</script>
<style lang="scss" module>
+.instanceIcon {
+ width: 18px;
+ height: 18px;
+}
</style>
diff --git a/packages/frontend/src/components/MkCaptcha.vue b/packages/frontend/src/components/MkCaptcha.vue
index 21f604aa43..e19c6435ef 100644
--- a/packages/frontend/src/components/MkCaptcha.vue
+++ b/packages/frontend/src/components/MkCaptcha.vue
@@ -142,7 +142,7 @@ function reset() {
function remove() {
if (captcha.value.remove && captchaWidgetId.value) {
try {
- if (_DEV_) console.log('remove', props.provider, captchaWidgetId.value);
+ if (_DEV_) console.debug('remove', props.provider, captchaWidgetId.value);
captcha.value.remove(captchaWidgetId.value);
} catch (error: unknown) {
// ignore
diff --git a/packages/frontend/src/components/MkCode.core.vue b/packages/frontend/src/components/MkCode.core.vue
index 40f41f5d0f..36c08a8c64 100644
--- a/packages/frontend/src/components/MkCode.core.vue
+++ b/packages/frontend/src/components/MkCode.core.vue
@@ -52,7 +52,7 @@ async function fetchLanguage(to: string): Promise<void> {
return bundle.id === language || bundle.aliases?.includes(language);
});
if (bundles.length > 0) {
- if (_DEV_) console.log(`Loading language: ${language}`);
+ if (_DEV_) console.debug(`Loading language: ${language}`);
await highlighter.loadLanguage(bundles[0].import);
codeLang.value = language;
} else {
diff --git a/packages/frontend/src/components/MkDateSeparatedList.vue b/packages/frontend/src/components/MkDateSeparatedList.vue
index 63e6b74154..8cf4e5fa2d 100644
--- a/packages/frontend/src/components/MkDateSeparatedList.vue
+++ b/packages/frontend/src/components/MkDateSeparatedList.vue
@@ -16,6 +16,7 @@ import { instance } from '@/instance.js';
import { prefer } from '@/preferences.js';
import { getDateText } from '@/utility/timeline-date-separate.js';
import { $i } from '@/i.js';
+import SkTransitionGroup from '@/components/SkTransitionGroup.vue';
export default defineComponent({
props: {
@@ -146,14 +147,12 @@ export default defineComponent({
[$style['direction-up']]: props.direction === 'up',
};
- return () => prefer.s.animation ? h(TransitionGroup, {
+ return () => h(SkTransitionGroup, {
class: classes,
name: 'list',
tag: 'div',
onBeforeLeave,
onLeaveCancelled,
- }, { default: renderChildren }) : h('div', {
- class: classes,
}, { default: renderChildren });
},
});
diff --git a/packages/frontend/src/components/MkDonation.vue b/packages/frontend/src/components/MkDonation.vue
index 43d2002204..dfdfc0a871 100644
--- a/packages/frontend/src/components/MkDonation.vue
+++ b/packages/frontend/src/components/MkDonation.vue
@@ -53,6 +53,7 @@ import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { miLocalStorage } from '@/local-storage.js';
import { instance } from '@/instance.js';
+import { prefer } from '@/preferences.js';
const emit = defineEmits<{
(ev: 'closed'): void;
@@ -66,6 +67,7 @@ function close() {
}
function neverShow() {
+ prefer.commit('neverShowDonationInfo', 'true');
miLocalStorage.setItem('neverShowDonationInfo', 'true');
close();
}
diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue
index 2e5d0a3dea..4537bc9d82 100644
--- a/packages/frontend/src/components/MkFolder.vue
+++ b/packages/frontend/src/components/MkFolder.vue
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div ref="rootEl" :class="$style.root" role="group" :aria-expanded="opened">
- <MkStickyContainer>
+ <MkStickyContainer :sticky="sticky">
<template #header>
<button :class="[$style.header, { [$style.opened]: opened }]" class="_button" role="button" data-cy-folder-header @click="toggle">
<div :class="$style.headerIcon"><slot name="icon"></slot></div>
@@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only
>
<KeepAlive>
<div v-show="opened">
- <MkStickyContainer>
+ <MkStickyContainer :sticky="sticky">
<template #header>
<div v-if="$slots.header" :class="$style.inBodyHeader">
<slot name="header"></slot>
@@ -73,12 +73,14 @@ const props = withDefaults(defineProps<{
withSpacer?: boolean;
spacerMin?: number;
spacerMax?: number;
+ sticky?: boolean;
}>(), {
defaultOpen: false,
maxHeight: null,
withSpacer: true,
spacerMin: 14,
spacerMax: 22,
+ sticky: true,
});
const rootEl = useTemplateRef('rootEl');
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index 366321565d..56bfa5de94 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -72,20 +72,21 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-show="mergedCW == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]">
<div :class="$style.text">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
- <MkA v-if="appearNote.replyId" :class="$style.replyIcon" :to="`/notes/${appearNote.replyId}`"><i class="ph-arrow-bend-left-up ph-bold ph-lg"></i></MkA>
- <Mfm
- v-if="appearNote.text"
- :parsedNodes="parsed"
- :text="appearNote.text"
- :author="appearNote.user"
- :nyaize="'respect'"
- :emojiUrls="appearNote.emojis"
- :enableEmojiMenu="true"
- :enableEmojiMenuReaction="true"
- :isAnim="allowAnim"
- :isBlock="true"
- class="_selectable"
- />
+ <div>
+ <MkA v-if="appearNote.replyId" :class="$style.replyIcon" :to="`/notes/${appearNote.replyId}`"><i class="ph-arrow-bend-left-up ph-bold ph-lg"></i></MkA>
+ <Mfm
+ v-if="appearNote.text"
+ :parsedNodes="parsed"
+ :text="appearNote.text"
+ :author="appearNote.user"
+ :nyaize="'respect'"
+ :emojiUrls="appearNote.emojis"
+ :enableEmojiMenu="true"
+ :enableEmojiMenuReaction="true"
+ :isAnim="allowAnim"
+ class="_selectable"
+ />
+ </div>
<SkNoteTranslation :note="note" :translation="translation" :translating="translating"></SkNoteTranslation>
<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>
<MkButton v-else-if="!prefer.s.animatedMfm && allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton>
@@ -95,7 +96,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :author="appearNote.user" :emojiUrls="appearNote.emojis" :class="$style.poll" @click.stop/>
<div v-if="isEnabledUrlPreview">
- <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" :class="$style.urlPreview" @click.stop/>
+ <SkUrlPreviewGroup :sourceUrls="urls" :sourceNote="appearNote" :compact="true" :detail="false" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" :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">
@@ -113,7 +114,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkA :to="`/notes/${appearNote.id}/reactions`" :class="[$style.reactionOmitted]">{{ i18n.ts.more }}</MkA>
</template>
</MkReactionsViewer>
- <footer :class="$style.footer">
+ <footer :class="$style.footer" class="_gaps _h_gaps" tabindex="0" role="group" :aria-label="i18n.ts.noteFooterLabel">
<button :class="$style.footerButton" class="_button" @click.stop @click="reply()">
<i class="ti ti-arrow-back-up"></i>
<p v-if="appearNote.repliesCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.repliesCount) }}</p>
@@ -157,7 +158,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @click.stop="clip()">
<i class="ti ti-paperclip"></i>
</button>
- <button v-if="prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable" ref="translationButton" class="_button" :class="$style.footerButton" :disabled="translating || !!translation" @click.stop="translate()">
+ <button v-if="prefer.s.showTranslationButtonInNoteFooter && policies.canUseTranslator && instance.translatorAvailable" ref="translationButton" class="_button" :class="$style.footerButton" :disabled="translating || !!translation" @click.stop="translate()">
<i class="ti ti-language-hiragana"></i>
</button>
<button ref="menuButton" :class="$style.footerButton" class="_button" @click.stop="showMenu()">
@@ -180,7 +181,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, inject, onMounted, ref, useTemplateRef, watch, provide } from 'vue';
-import * as mfm from '@transfem-org/sfm-js';
+import * as mfm from 'mfm-js';
import * as Misskey from 'misskey-js';
import { isLink } from '@@/js/is-link.js';
import { shouldCollapsed } from '@@/js/collapsed.js';
@@ -226,7 +227,7 @@ import { getNoteSummary } from '@/utility/get-note-summary.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { showMovedDialog } from '@/utility/show-moved-dialog.js';
import { boostMenuItems, computeRenoteTooltip } from '@/utility/boost-quote.js';
-import { instance, isEnabledUrlPreview } from '@/instance.js';
+import { instance, isEnabledUrlPreview, policies } from '@/instance.js';
import { focusPrev, focusNext } from '@/utility/focus.js';
import { getAppearNote } from '@/utility/get-appear-note.js';
import { prefer } from '@/preferences.js';
@@ -237,6 +238,7 @@ import SkMutedNote from '@/components/SkMutedNote.vue';
import SkNoteTranslation from '@/components/SkNoteTranslation.vue';
import { getSelfNoteIds } from '@/utility/get-self-note-ids.js';
import { extractPreviewUrls } from '@/utility/extract-preview-urls.js';
+import SkUrlPreviewGroup from '@/components/SkUrlPreviewGroup.vue';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
@@ -304,9 +306,9 @@ const galleryEl = useTemplateRef('galleryEl');
const isMyRenote = $i && ($i.id === note.value.userId);
const showContent = ref(prefer.s.uncollapseCW);
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null);
-const urls = computed(() => parsed.value ? extractPreviewUrls(props.note, parsed.value) : null);
+const urls = computed(() => parsed.value ? extractPreviewUrls(appearNote.value, parsed.value) : []);
const selfNoteIds = computed(() => getSelfNoteIds(props.note));
-const isLong = shouldCollapsed(appearNote.value, urls.value ?? []);
+const isLong = shouldCollapsed(appearNote.value, urls.value);
const collapsed = ref(prefer.s.expandLongNote && appearNote.value.cw == null && isLong ? false : appearNote.value.cw == null && isLong);
const isDeleted = ref(false);
const renoted = ref(false);
@@ -360,7 +362,7 @@ const keymap = {
clip();
},
't': () => {
- if (prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable) {
+ if (prefer.s.showTranslationButtonInNoteFooter && policies.value.canUseTranslator && instance.translatorAvailable) {
translate();
}
},
@@ -913,11 +915,11 @@ function emitUpdReaction(emoji: string, delta: number) {
.footer {
display: flex;
align-items: center;
- justify-content: space-between;
+ justify-content: flex-start;
position: relative;
z-index: 1;
margin-top: 0.4em;
- max-width: 400px;
+ overflow-x: auto;
}
&:hover > .article > .main > .footer > .footerButton {
@@ -1203,10 +1205,6 @@ function emitUpdReaction(emoji: string, delta: number) {
padding: 8px;
opacity: 0.7;
- &:not(:last-child) {
- margin-right: 1.5em;
- }
-
&:hover {
color: var(--MI_THEME-fgHighlighted);
}
@@ -1290,25 +1288,7 @@ function emitUpdReaction(emoji: string, delta: number) {
}
}
-@container (max-width: 400px) {
- .root:not(.showActionsOnlyHover) {
- .footerButton {
- &:not(:last-child) {
- margin-right: 0.2em;
- }
- }
- }
-}
-
@container (max-width: 350px) {
- .root:not(.showActionsOnlyHover) {
- .footerButton {
- &:not(:last-child) {
- margin-right: 0.1em;
- }
- }
- }
-
.colorBar {
top: 6px;
left: 6px;
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index 52cc836926..7f38b9ec02 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -89,21 +89,21 @@ SPDX-License-Identifier: AGPL-3.0-only
</p>
<div v-show="mergedCW == 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"
- :parsedNodes="parsed"
- :text="appearNote.text"
- :author="appearNote.user"
- :nyaize="'respect'"
- :emojiUrls="appearNote.emojis"
- :enableEmojiMenu="true"
- :enableEmojiMenuReaction="true"
- :isAnim="allowAnim"
- :isBlock="true"
- class="_selectable"
- />
- <a v-if="appearNote.renote != null" :class="$style.rn">RN:</a>
+ <div>
+ <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"
+ :parsedNodes="parsed"
+ :text="appearNote.text"
+ :author="appearNote.user"
+ :nyaize="'respect'"
+ :emojiUrls="appearNote.emojis"
+ :enableEmojiMenu="true"
+ :enableEmojiMenuReaction="true"
+ :isAnim="allowAnim"
+ class="_selectable"
+ />
+ </div>
<SkNoteTranslation :note="note" :translation="translation" :translating="translating"></SkNoteTranslation>
<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>
<MkButton v-else-if="!prefer.s.animatedMfm && allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton>
@@ -112,13 +112,13 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :class="$style.poll" :author="appearNote.user" :emojiUrls="appearNote.emojis"/>
<div v-if="isEnabledUrlPreview">
- <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" style="margin-top: 6px;"/>
+ <SkUrlPreviewGroup :sourceNodes="nodes" :sourceNote="appearNote" :compact="true" :detail="true" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" style="margin-top: 6px;" @click.stop/>
</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="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA>
</div>
- <footer :class="$style.footer">
+ <footer :class="$style.footer" class="_gaps _h_gaps" tabindex="0" role="group" :aria-label="i18n.ts.noteFooterLabel">
<div :class="$style.noteFooterInfo">
<div v-if="appearNote.updatedAt">
{{ i18n.ts.edited }}: <MkTime :time="appearNote.updatedAt" mode="detail"/>
@@ -169,7 +169,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @click.stop="clip()">
<i class="ti ti-paperclip"></i>
</button>
- <button v-if="prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable" ref="translationButton" class="_button" :class="$style.noteFooterButton" :disabled="translating || !!translation" @click.stop="translate()">
+ <button v-if="prefer.s.showTranslationButtonInNoteFooter && policies.canUseTranslator && instance.translatorAvailable" ref="translationButton" class="_button" :class="$style.noteFooterButton" :disabled="translating || !!translation" @click.stop="translate()">
<i class="ti ti-language-hiragana"></i>
</button>
<button ref="menuButton" class="_button" :class="$style.noteFooterButton" @click.stop="showMenu()">
@@ -233,7 +233,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, inject, onMounted, provide, ref, useTemplateRef, watch } from 'vue';
-import * as mfm from '@transfem-org/sfm-js';
+import * as mfm from 'mfm-js';
import * as Misskey from 'misskey-js';
import { isLink } from '@@/js/is-link.js';
import * as config from '@@/js/config.js';
@@ -278,7 +278,7 @@ import MkPagination from '@/components/MkPagination.vue';
import MkReactionIcon from '@/components/MkReactionIcon.vue';
import MkButton from '@/components/MkButton.vue';
import { boostMenuItems, computeRenoteTooltip } from '@/utility/boost-quote.js';
-import { instance, isEnabledUrlPreview } from '@/instance.js';
+import { instance, isEnabledUrlPreview, policies } from '@/instance.js';
import { getAppearNote } from '@/utility/get-appear-note.js';
import { prefer } from '@/preferences.js';
import { getPluginHandlers } from '@/plugin.js';
@@ -286,7 +286,7 @@ import { DI } from '@/di.js';
import SkMutedNote from '@/components/SkMutedNote.vue';
import SkNoteTranslation from '@/components/SkNoteTranslation.vue';
import { getSelfNoteIds } from '@/utility/get-self-note-ids.js';
-import { extractPreviewUrls } from '@/utility/extract-preview-urls.js';
+import SkUrlPreviewGroup from '@/components/SkUrlPreviewGroup.vue';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
@@ -339,8 +339,7 @@ const isDeleted = ref(false);
const renoted = ref(false);
const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null);
const translating = ref(false);
-const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null);
-const urls = computed(() => parsed.value ? extractPreviewUrls(props.note, parsed.value) : null);
+const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : []);
const selfNoteIds = computed(() => getSelfNoteIds(props.note));
const animated = computed(() => parsed.value ? checkAnimationFromMfm(parsed.value) : null);
const allowAnim = ref(prefer.s.advancedMfm && prefer.s.animatedMfm);
@@ -388,7 +387,7 @@ const keymap = {
clip();
},
't': () => {
- if (prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable) {
+ if (prefer.s.showTranslationButtonInNoteFooter && policies.value.canUseTranslator && instance.translatorAvailable) {
translate();
}
},
@@ -415,6 +414,11 @@ provide(DI.mfmEmojiReactCallback, (reaction) => {
const tab = ref(props.initialTab);
const reactionTabType = ref<string | null>(null);
+// Auto-select the first page of reactions
+watch(appearNote, n => {
+ reactionTabType.value ??= Object.keys(n.reactions)[0] ?? null;
+}, { immediate: true });
+
const renotesPagination = computed<Paging>(() => ({
endpoint: 'notes/renotes',
limit: 10,
@@ -886,12 +890,10 @@ function animatedMFM() {
}
.footer {
- position: relative;
- z-index: 1;
- margin-top: 0.4em;
- width: max-content;
- min-width: min-content;
- max-width: fit-content;
+ position: relative;
+ z-index: 1;
+ margin-top: 0.4em;
+ overflow-x: auto;
}
.replyTo {
@@ -1083,10 +1085,6 @@ function animatedMFM() {
padding: 8px;
opacity: 0.7;
- &:not(:last-child) {
- margin-right: 1.5em;
- }
-
&:hover {
color: var(--MI_THEME-fgHighlighted);
}
@@ -1169,14 +1167,6 @@ function animatedMFM() {
}
}
-@container (max-width: 350px) {
- .noteFooterButton {
- &:not(:last-child) {
- margin-right: 0.1em;
- }
- }
-}
-
@container (max-width: 300px) {
.root {
font-size: 0.825em;
@@ -1186,12 +1176,6 @@ function animatedMFM() {
width: 50px;
height: 50px;
}
-
- .noteFooterButton {
- &:not(:last-child) {
- margin-right: 0.1em;
- }
- }
}
.muted {
diff --git a/packages/frontend/src/components/MkNoteSub.vue b/packages/frontend/src/components/MkNoteSub.vue
index 282854c6a8..58de5bd5a7 100644
--- a/packages/frontend/src/components/MkNoteSub.vue
+++ b/packages/frontend/src/components/MkNoteSub.vue
@@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSubNoteContent :class="$style.text" :note="note" :translating="translating" :translation="translation" :expandAllCws="props.expandAllCws"/>
</div>
</div>
- <footer :class="$style.footer">
+ <footer :class="$style.footer" class="_gaps _h_gaps" tabindex="0" role="group" :aria-label="i18n.ts.noteFooterLabel">
<MkReactionsViewer ref="reactionsViewer" :note="note"/>
<button class="_button" :class="$style.noteFooterButton" @click="reply()">
<i class="ph-arrow-u-up-left ph-bold ph-lg"></i>
@@ -62,7 +62,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" :class="$style.noteFooterButton" class="_button" @click.stop="clip()">
<i class="ti ti-paperclip"></i>
</button>
- <button v-if="prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable" ref="translationButton" class="_button" :class="$style.noteFooterButton" :disabled="translating || !!translation" @click.stop="translate()">
+ <button v-if="prefer.s.showTranslationButtonInNoteFooter && policies.canUseTranslator && instance.translatorAvailable" ref="translationButton" class="_button" :class="$style.noteFooterButton" :disabled="translating || !!translation" @click.stop="translate()">
<i class="ti ti-language-hiragana"></i>
</button>
<button ref="menuButton" class="_button" :class="$style.noteFooterButton" @click.stop="menu()">
@@ -113,7 +113,8 @@ import { boostMenuItems, computeRenoteTooltip } from '@/utility/boost-quote.js';
import { prefer } from '@/preferences.js';
import { useNoteCapture } from '@/use/use-note-capture.js';
import SkMutedNote from '@/components/SkMutedNote.vue';
-import { instance } from '@/instance';
+import { instance, policies } from '@/instance';
+import { getAppearNote } from '@/utility/get-appear-note';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
@@ -128,7 +129,9 @@ const props = withDefaults(defineProps<{
onDeleteCallback: undefined,
});
-const canRenote = computed(() => ['public', 'home'].includes(props.note.visibility) || props.note.userId === $i?.id);
+const appearNote = computed(() => getAppearNote(props.note));
+
+const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || appearNote.value.userId === $i?.id);
const el = shallowRef<HTMLElement>();
const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null);
@@ -144,19 +147,11 @@ const likeButton = shallowRef<HTMLElement>();
const renoteTooltip = computeRenoteTooltip(renoted);
-const appearNote = computed(() => isRenote ? props.note.renote as Misskey.entities.Note : props.note);
const defaultLike = computed(() => prefer.s.like ? prefer.s.like : null);
const replies = ref<Misskey.entities.Note[]>([]);
const mergedCW = computed(() => computeMergedCw(appearNote.value));
-const isRenote = (
- props.note.renote != null &&
- props.note.text == null &&
- props.note.fileIds && props.note.fileIds.length === 0 &&
- props.note.poll == null
-);
-
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
type: 'lookup',
url: appearNote.value.url ?? appearNote.value.uri ?? `${config.url}/notes/${appearNote.value.id}`,
@@ -206,8 +201,8 @@ async function reply(viaKeyboard = false): Promise<void> {
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
showMovedDialog();
await os.post({
- reply: props.note,
- channel: props.note.channel ?? undefined,
+ reply: appearNote.value,
+ channel: appearNote.value.channel ?? undefined,
animation: !viaKeyboard,
});
focus();
@@ -217,9 +212,9 @@ function react(): void {
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
showMovedDialog();
sound.playMisskeySfx('reaction');
- if (props.note.reactionAcceptance === 'likeOnly') {
+ if (appearNote.value.reactionAcceptance === 'likeOnly') {
misskeyApi('notes/like', {
- noteId: props.note.id,
+ noteId: appearNote.value.id,
override: defaultLike.value,
});
const el = reactButton.value as HTMLElement | null | undefined;
@@ -233,12 +228,12 @@ function react(): void {
}
} else {
blur();
- reactionPicker.show(reactButton.value ?? null, props.note, reaction => {
+ reactionPicker.show(reactButton.value ?? null, appearNote.value, reaction => {
misskeyApi('notes/reactions/create', {
- noteId: props.note.id,
+ noteId: appearNote.value.id,
reaction: reaction,
});
- if (props.note.text && props.note.text.length > 100 && (Date.now() - new Date(props.note.createdAt).getTime() < 1000 * 3)) {
+ if (appearNote.value.text && appearNote.value.text.length > 100 && (Date.now() - new Date(appearNote.value.createdAt).getTime() < 1000 * 3)) {
claimAchievement('reactWithoutRead');
}
}, () => {
@@ -252,7 +247,7 @@ function like(): void {
showMovedDialog();
sound.playMisskeySfx('reaction');
misskeyApi('notes/like', {
- noteId: props.note.id,
+ noteId: appearNote.value.id,
override: defaultLike.value,
});
const el = likeButton.value as HTMLElement | null | undefined;
@@ -361,7 +356,7 @@ function quote() {
}).then((cancelled) => {
if (cancelled) return;
misskeyApi('notes/renotes', {
- noteId: props.note.id,
+ noteId: appearNote.value.id,
userId: $i?.id,
limit: 1,
quote: true,
@@ -383,12 +378,12 @@ function quote() {
}
function menu(): void {
- const { menu, cleanup } = getNoteMenu({ note: props.note, translating, translation, isDeleted });
+ const { menu, cleanup } = getNoteMenu({ note: appearNote.value, translating, translation, isDeleted });
os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup);
}
async function clip(): Promise<void> {
- os.popupMenu(await getNoteClipMenu({ note: props.note, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus);
+ os.popupMenu(await getNoteClipMenu({ note: appearNote.value, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus);
}
async function translate() {
@@ -397,7 +392,7 @@ async function translate() {
if (props.detail) {
misskeyApi('notes/children', {
- noteId: props.note.id,
+ noteId: appearNote.value.id,
limit: prefer.s.numberOfReplies,
showQuotes: false,
}).then(res => {
@@ -419,12 +414,10 @@ if (props.detail) {
}
.footer {
- position: relative;
- z-index: 1;
- margin-top: 0.4em;
- width: max-content;
- min-width: min-content;
- max-width: fit-content;
+ position: relative;
+ z-index: 1;
+ margin-top: 0.4em;
+ overflow-x: auto;
}
.main {
@@ -469,23 +462,11 @@ if (props.detail) {
padding-top: 10px;
opacity: 0.7;
- &:not(:last-child) {
- margin-right: 1.5em;
- }
-
&:hover {
color: var(--MI_THEME-fgHighlighted);
}
}
-@container (max-width: 400px) {
- .noteFooterButton {
- &:not(:last-child) {
- margin-right: 0.7em;
- }
- }
-}
-
.noteFooterButtonCount {
display: inline;
margin: 0 0 0 8px;
diff --git a/packages/frontend/src/components/MkNotes.vue b/packages/frontend/src/components/MkNotes.vue
index efb481d01d..04dff2eb36 100644
--- a/packages/frontend/src/components/MkNotes.vue
+++ b/packages/frontend/src/components/MkNotes.vue
@@ -15,13 +15,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #default="{ items: notes }">
<div :class="[$style.root, { [$style.noGap]: noGap, '_gaps': !noGap, [$style.reverse]: pagination.reversed }]">
<template v-for="(note, i) in notes" :key="note.id">
- <div v-if="note._shouldInsertAd_" :class="[$style.noteWithAd, { '_gaps': !noGap }]" :data-scroll-anchor="note.id">
- <DynamicNote :class="$style.note" :note="note as Misskey.entities.Note" :withHardMute="true"/>
- <div :class="$style.ad">
- <MkAd :preferForms="['horizontal', 'horizontal-big']"/>
- </div>
- </div>
- <DynamicNote v-else :class="$style.note" :note="note as Misskey.entities.Note" :withHardMute="true" :data-scroll-anchor="note.id"/>
+ <DynamicNote :class="$style.note" :note="note as Misskey.entities.Note" :withHardMute="true" :data-scroll-anchor="note.id"/>
+ <MkAd v-if="note._shouldInsertAd_" :preferForms="['horizontal', 'horizontal-big']" :class="$style.ad"/>
</template>
</div>
</template>
@@ -62,7 +57,7 @@ defineExpose({
&.noGap {
background: color-mix(in srgb, var(--MI_THEME-panel) 65%, transparent);
- .note {
+ .note:not(:empty) {
border-bottom: solid 0.5px var(--MI_THEME-divider);
}
diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue
index 54edf771ed..46e98462dc 100644
--- a/packages/frontend/src/components/MkNotifications.vue
+++ b/packages/frontend/src/components/MkNotifications.vue
@@ -14,8 +14,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<template #default="{ items: notifications }">
- <component
- :is="prefer.s.animation ? TransitionGroup : 'div'" :class="[$style.notifications]"
+ <SkTransitionGroup
+ :class="[$style.notifications]"
:enterActiveClass="$style.transition_x_enterActive"
:leaveActiveClass="$style.transition_x_leaveActive"
:enterFromClass="$style.transition_x_enterFrom"
@@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<DynamicNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :class="$style.item" :note="notification.note" :withHardMute="true" :data-scroll-anchor="notification.id"/>
<XNotification v-else :class="$style.item" :notification="notification" :withTime="true" :full="true" :data-scroll-anchor="notification.id"/>
</div>
- </component>
+ </SkTransitionGroup>
</template>
</MkPagination>
</MkPullToRefresh>
@@ -45,6 +45,7 @@ import { i18n } from '@/i18n.js';
import { infoImageUrl } from '@/instance.js';
import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
import { prefer } from '@/preferences.js';
+import SkTransitionGroup from '@/components/SkTransitionGroup.vue';
const props = defineProps<{
excludeTypes?: typeof notificationTypes[number][];
diff --git a/packages/frontend/src/components/MkPageWindow.vue b/packages/frontend/src/components/MkPageWindow.vue
index a9e4704b24..44112775dc 100644
--- a/packages/frontend/src/components/MkPageWindow.vue
+++ b/packages/frontend/src/components/MkPageWindow.vue
@@ -106,7 +106,7 @@ windowRouter.addListener('replace', ctx => {
});
windowRouter.addListener('change', ctx => {
- if (_DEV_) console.log('windowRouter: change', ctx.fullPath);
+ if (_DEV_) console.debug('windowRouter: change', ctx.fullPath);
searchMarkerId.value = getSearchMarker(ctx.fullPath);
});
diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue
index 79a268e8f6..b850c17be1 100644
--- a/packages/frontend/src/components/MkPagination.vue
+++ b/packages/frontend/src/components/MkPagination.vue
@@ -386,7 +386,7 @@ function prepend(item: MisskeyEntity): void {
return;
}
- if (_DEV_) console.log(isHead(), isPausingUpdate);
+ if (_DEV_) console.debug(isHead(), isPausingUpdate);
if (isHead() && !isPausingUpdate) unshiftItems([item]);
else prependQueue(item);
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index 000ccf50bf..a650365a28 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -107,7 +107,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { inject, watch, nextTick, onMounted, defineAsyncComponent, provide, shallowRef, ref, computed, useTemplateRef, toRaw } from 'vue';
-import * as mfm from '@transfem-org/sfm-js';
+import * as mfm from 'mfm-js';
import * as Misskey from 'misskey-js';
import insertTextAtCursor from 'insert-text-at-cursor';
import { toASCII } from 'punycode.js';
@@ -373,7 +373,9 @@ if (props.specified) {
// keep cw when reply
if (prefer.s.keepCw && props.reply && props.reply.cw) {
useCw.value = true;
- cw.value = props.reply.cw;
+ cw.value = (prefer.s.keepCw === 'prepend-re' && !props.reply.cw.toLowerCase().startsWith('re:'))
+ ? `RE: ${props.reply.cw}`
+ : props.reply.cw;
}
// apply default CW
@@ -557,6 +559,7 @@ async function toggleLocalOnly() {
if (confirm.result === 'no') return;
if (confirm.result === 'neverShow') {
+ prefer.commit('neverShowLocalOnlyInfo', 'true');
miLocalStorage.setItem('neverShowLocalOnlyInfo', 'true');
}
}
@@ -1356,7 +1359,7 @@ defineExpose({
}
&.danger {
- color: #ff2a2a;
+ color: var(--MI_THEME-warn);
}
}
diff --git a/packages/frontend/src/components/MkReactionsViewer.vue b/packages/frontend/src/components/MkReactionsViewer.vue
index 945640ab41..88ac8c87c1 100644
--- a/packages/frontend/src/components/MkReactionsViewer.vue
+++ b/packages/frontend/src/components/MkReactionsViewer.vue
@@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
-<component
- :is="prefer.s.animation ? TransitionGroup : 'div'"
+<SkTransitionGroup
:enterActiveClass="$style.transition_x_enterActive"
:leaveActiveClass="$style.transition_x_leaveActive"
:enterFromClass="$style.transition_x_enterFrom"
@@ -14,8 +13,10 @@ SPDX-License-Identifier: AGPL-3.0-only
tag="div" :class="$style.root"
>
<XReaction v-for="[reaction, count] in reactions" :key="reaction" :reaction="reaction" :count="count" :isInitial="initialReactions.has(reaction)" :note="note" @reactionToggled="onMockToggleReaction"/>
- <slot v-if="hasMoreReactions" :key="'$more'" name="more"/>
-</component>
+ <div v-if="hasMoreReactions" :key="'$more'" :class="$style.moreReactions">
+ <slot name="more"/>
+ </div>
+</SkTransitionGroup>
</template>
<script lang="ts" setup>
@@ -25,6 +26,7 @@ import { TransitionGroup } from 'vue';
import XReaction from '@/components/MkReactionsViewer.reaction.vue';
import { prefer } from '@/preferences.js';
import { DI } from '@/di.js';
+import SkTransitionGroup from '@/components/SkTransitionGroup.vue';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
@@ -102,7 +104,7 @@ watch([() => props.note.reactions, () => props.maxNumber], ([newSource, maxNumbe
position: absolute;
}
-.root {
+.root, .moreReactions {
display: flex;
flex-wrap: wrap;
align-items: center;
diff --git a/packages/frontend/src/components/MkSelect.vue b/packages/frontend/src/components/MkSelect.vue
index cf4e4eda74..511a45c165 100644
--- a/packages/frontend/src/components/MkSelect.vue
+++ b/packages/frontend/src/components/MkSelect.vue
@@ -39,32 +39,34 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</template>
-<script lang="ts" setup>
-import { onMounted, nextTick, ref, watch, computed, toRefs, useSlots } from 'vue';
-import { useInterval } from '@@/js/use-interval.js';
-import type { VNode, VNodeChild } from 'vue';
-import type { MenuItem } from '@/types/menu.js';
-import * as os from '@/os.js';
-
-type ItemOption = {
+<script lang="ts">
+type ItemOption<T extends string | number | null | boolean = string | number | null> = {
type?: 'option';
- value: string | number | null;
+ value: T;
label: string;
};
-type ItemGroup = {
+type ItemGroup<T extends string | number | null | boolean = string | number | null> = {
type: 'group';
label: string;
- items: ItemOption[];
+ items: ItemOption<T>[];
};
-export type MkSelectItem = ItemOption | ItemGroup;
+export type MkSelectItem<T extends string | number | null | boolean = string | number | null> = ItemOption<T> | ItemGroup<T>;
+</script>
+
+<script lang="ts" setup generic="T extends string | number | null | boolean = string | number | null">
+import { onMounted, nextTick, ref, watch, computed, toRefs, useSlots } from 'vue';
+import { useInterval } from '@@/js/use-interval.js';
+import type { VNode, VNodeChild } from 'vue';
+import type { MenuItem } from '@/types/menu.js';
+import * as os from '@/os.js';
// TODO: itemsをslot内のoptionで指定する用法は廃止する(props.itemsを必須化する)
// see: https://github.com/misskey-dev/misskey/issues/15558
const props = defineProps<{
- modelValue: string | number | null;
+ modelValue: T;
required?: boolean;
readonly?: boolean;
disabled?: boolean;
@@ -73,11 +75,11 @@ const props = defineProps<{
inline?: boolean;
small?: boolean;
large?: boolean;
- items?: MkSelectItem[];
+ items?: MkSelectItem<T>[];
}>();
const emit = defineEmits<{
- (ev: 'update:modelValue', value: string | number | null): void;
+ (ev: 'update:modelValue', value: T): void;
}>();
const slots = useSlots();
diff --git a/packages/frontend/src/components/MkSignupDialog.form.vue b/packages/frontend/src/components/MkSignupDialog.form.vue
index 365b23f4ce..003c68309d 100644
--- a/packages/frontend/src/components/MkSignupDialog.form.vue
+++ b/packages/frontend/src/components/MkSignupDialog.form.vue
@@ -307,7 +307,7 @@ async function onSubmit(): Promise<void> {
emit('approvalPending');
} else {
const resJson = (await res.json()) as Misskey.entities.SignupResponse;
- if (_DEV_) console.log(resJson);
+ if (_DEV_) console.debug(resJson);
emit('signup', resJson);
diff --git a/packages/frontend/src/components/MkSubNoteContent.vue b/packages/frontend/src/components/MkSubNoteContent.vue
index 0780f6c910..60d303f937 100644
--- a/packages/frontend/src/components/MkSubNoteContent.vue
+++ b/packages/frontend/src/components/MkSubNoteContent.vue
@@ -8,8 +8,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="{ [$style.clickToOpen]: prefer.s.clickToOpen }" @click.stop="prefer.s.clickToOpen ? noteclick(note.id) : undefined">
<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" :isBlock="true" :author="note.user" :nyaize="'respect'" :isAnim="allowAnim" :emojiUrls="note.emojis"/>
+ <div>
+ <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"/>
+ </div>
<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="!prefer.s.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>
<SkNoteTranslation :note="note" :translation="translation" :translating="translating"></SkNoteTranslation>
@@ -35,7 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { ref, computed, watch } from 'vue';
import * as Misskey from 'misskey-js';
-import * as mfm from '@transfem-org/sfm-js';
+import * as mfm from 'mfm-js';
import { shouldCollapsed } from '@@/js/collapsed.js';
import MkMediaList from '@/components/MkMediaList.vue';
import MkPoll from '@/components/MkPoll.vue';
diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue
index 48e8c7f377..61b34b561d 100644
--- a/packages/frontend/src/components/MkTimeline.vue
+++ b/packages/frontend/src/components/MkTimeline.vue
@@ -14,8 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<template #default="{ items: notes }">
- <component
- :is="prefer.s.animation ? TransitionGroup : 'div'"
+ <SkTransitionGroup
:class="[$style.root, { [$style.noGap]: noGap, '_gaps': !noGap, [$style.reverse]: paginationQuery.reversed }]"
:enterActiveClass="$style.transition_x_enterActive"
:leaveActiveClass="$style.transition_x_leaveActive"
@@ -24,16 +23,11 @@ SPDX-License-Identifier: AGPL-3.0-only
:moveClass=" $style.transition_x_move"
tag="div"
>
- <div v-for="(note, i) in notes" :key="note.id">
- <div v-if="note._shouldInsertAd_" :class="[$style.noteWithAd, { '_gaps': !noGap }]" :data-scroll-anchor="note.id">
- <DynamicNote :class="$style.note" :note="note" :withHardMute="true"/>
- <div :class="$style.ad">
- <MkAd :preferForms="['horizontal', 'horizontal-big']"/>
- </div>
- </div>
- <DynamicNote v-else :class="$style.note" :note="note" :withHardMute="true" :data-scroll-anchor="note.id"/>
+ <div v-for="(note, i) in notes" :key="note.id" :class="{ '_gaps': !noGap }">
+ <DynamicNote :class="$style.note" :note="note as Misskey.entities.Note" :withHardMute="true" :data-scroll-anchor="note.id"/>
+ <MkAd v-if="note._shouldInsertAd_" :preferForms="['horizontal', 'horizontal-big']" :class="$style.ad"/>
</div>
- </component>
+ </SkTransitionGroup>
</template>
</MkPagination>
</MkPullToRefresh>
@@ -54,6 +48,7 @@ import DynamicNote from '@/components/DynamicNote.vue';
import MkPagination from '@/components/MkPagination.vue';
import { i18n } from '@/i18n.js';
import { infoImageUrl } from '@/instance.js';
+import SkTransitionGroup from '@/components/SkTransitionGroup.vue';
const props = withDefaults(defineProps<{
src: BasicTimelineType | 'mentions' | 'directs' | 'list' | 'antenna' | 'channel' | 'role';
@@ -358,7 +353,7 @@ defineExpose({
&.noGap {
background: var(--MI_THEME-panel);
- .note {
+ .note:not(:empty) {
border-bottom: solid 0.5px var(--MI_THEME-divider);
}
diff --git a/packages/frontend/src/components/MkUrlPreview.vue b/packages/frontend/src/components/MkUrlPreview.vue
index a14c2ecef9..5d0e6e3df7 100644
--- a/packages/frontend/src/components/MkUrlPreview.vue
+++ b/packages/frontend/src/components/MkUrlPreview.vue
@@ -65,6 +65,17 @@ SPDX-License-Identifier: AGPL-3.0-only
</footer>
</article>
</component>
+
+ <I18n v-if="attributionUser" :src="i18n.ts.writtenBy" :class="$style.linkAttribution" tag="p">
+ <template #user>
+ <MkA v-user-preview="attributionUser.id" :to="userPage(attributionUser)">
+ <MkAvatar :class="$style.linkAttributionIcon" :user="attributionUser"/>
+ <MkUserName :user="attributionUser" style="color: var(--MI_THEME-accent)"/>
+ </MkA>
+ </template>
+ </I18n>
+ <p v-else-if="linkAttribution" :class="$style.linkAttribution"><MkEllipsis/></p>
+
<template v-if="showActions">
<div v-if="tweetId" :class="$style.action">
<MkButton :small="true" inline @click="tweetExpanded = true">
@@ -88,13 +99,24 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</template>
+<script lang="ts">
+// eslint-disable-next-line import/order
+import type { summaly } from '@misskey-dev/summaly';
+
+export type SummalyResult = Awaited<ReturnType<typeof summaly>> & {
+ haveNoteLocally?: boolean,
+ linkAttribution?: {
+ userId: string,
+ }
+};
+</script>
+
<script lang="ts" setup>
import { defineAsyncComponent, onDeactivated, onUnmounted, ref } from 'vue';
import { url as local } from '@@/js/config.js';
import { versatileLang } from '@@/js/intl-const.js';
import * as Misskey from 'misskey-js';
import { maybeMakeRelative } from '@@/js/url.js';
-import type { summaly } from '@misskey-dev/summaly';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { deviceKind } from '@/utility/device-kind.js';
@@ -106,8 +128,7 @@ import { misskeyApi } from '@/utility/misskey-api.js';
import { warningExternalWebsite } from '@/utility/warning-external-website.js';
import DynamicNoteSimple from '@/components/DynamicNoteSimple.vue';
import { $i } from '@/i';
-
-type SummalyResult = Awaited<ReturnType<typeof summaly>>;
+import { userPage } from '@/filters/user.js';
const props = withDefaults(defineProps<{
url: string;
@@ -116,12 +137,18 @@ const props = withDefaults(defineProps<{
showAsQuote?: boolean;
showActions?: boolean;
skipNoteIds?: (string | undefined)[];
+ previewHint?: SummalyResult;
+ noteHint?: Misskey.entities.Note | null;
+ attributionHint?: Misskey.entities.User | null;
}>(), {
detail: false,
compact: false,
showAsQuote: false,
showActions: true,
skipNoteIds: undefined,
+ previewHint: undefined,
+ noteHint: undefined,
+ attributionHint: undefined,
});
const MOBILE_THRESHOLD = 500;
@@ -146,6 +173,10 @@ const player = ref<SummalyResult['player']>({
height: null,
allow: [],
});
+const linkAttribution = ref<{
+ userId: string,
+} | null>(null);
+const attributionUser = ref<Misskey.entities.User | null>(null);
const playerEnabled = ref(false);
const tweetId = ref<string | null>(null);
const tweetExpanded = ref(props.detail);
@@ -154,12 +185,35 @@ const tweetHeight = ref(150);
const unknownUrl = ref(false);
const theNote = ref<Misskey.entities.Note | null>(null);
const fetchingTheNote = ref(false);
+const fetchingAttribution = ref<Promise<void> | null>(null);
onDeactivated(() => {
playerEnabled.value = false;
});
-async function fetchNote() {
+async function fetchAttribution(initial: boolean): Promise<void> {
+ if (!linkAttribution.value) return;
+ if (attributionUser.value) return;
+ if (fetchingAttribution.value) return fetchingAttribution.value;
+
+ return fetchingAttribution.value ??= (async (userId: string): Promise<void> => {
+ try {
+ if (initial && props.attributionHint !== undefined) {
+ attributionUser.value = props.attributionHint;
+ } else {
+ attributionUser.value = await misskeyApi('users/show', { userId });
+ }
+ } catch {
+ // makes the loading ellipsis vanish.
+ linkAttribution.value = null;
+ } finally {
+ // Reset promise to mark as done
+ fetchingAttribution.value = null;
+ }
+ })(linkAttribution.value.userId);
+}
+
+async function fetchNote(initial: boolean) {
if (!props.showAsQuote) return;
if (!activityPub.value) return;
if (theNote.value) return;
@@ -167,8 +221,15 @@ async function fetchNote() {
fetchingTheNote.value = true;
try {
- const response = await misskeyApi('ap/show', { uri: activityPub.value });
+ const response = (initial && props.noteHint !== undefined)
+ ? { type: 'Note', object: props.noteHint }
+ : await misskeyApi('ap/show', { uri: activityPub.value });
if (response.type !== 'Note') return;
+ if (!response.object) {
+ activityPub.value = null;
+ theNote.value = null;
+ return;
+ }
const theNoteId = response['object'].id;
if (theNoteId && props.skipNoteIds && props.skipNoteIds.includes(theNoteId)) {
hidePreview.value = true;
@@ -194,13 +255,16 @@ if (requestUrl.hostname === 'twitter.com' || requestUrl.hostname === 'mobile.twi
if (m) tweetId.value = m[1];
}
+// This is now handled on the backend
+/*
if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/(?:watch|channel)')) {
requestUrl.hostname = 'www.youtube.com';
}
requestUrl.hash = '';
+*/
-function refresh(withFetch = false) {
+function refresh(withFetch = false, initial = false) {
const params = new URLSearchParams({
url: requestUrl.href,
lang: versatileLang,
@@ -210,18 +274,21 @@ function refresh(withFetch = false) {
}
const headers = $i ? { Authorization: `Bearer ${$i.token}` } : undefined;
- return fetching.value ??= window.fetch(`/url?${params.toString()}`, { headers })
- .then(res => {
- if (!res.ok) {
- if (_DEV_) {
- console.warn(`[HTTP${res.status}] Failed to fetch url preview`);
+ const fetchPromise: Promise<SummalyResult | null> = (initial && props.previewHint)
+ ? Promise.resolve(props.previewHint)
+ : window.fetch(`/url?${params.toString()}`, { headers })
+ .then(res => {
+ if (!res.ok) {
+ if (_DEV_) {
+ console.warn(`[HTTP${res.status}] Failed to fetch url preview`);
+ }
+ return null;
}
- return null;
- }
- return res.json();
- })
- .then(async (info: SummalyResult & { haveNoteLocally?: boolean } | null) => {
+ return res.json();
+ });
+ return fetching.value ??= fetchPromise
+ .then(async (info: SummalyResult | null) => {
unknownUrl.value = info == null;
title.value = info?.title ?? null;
description.value = info?.description ?? null;
@@ -236,11 +303,16 @@ function refresh(withFetch = false) {
};
sensitive.value = info?.sensitive ?? false;
activityPub.value = info?.activityPub ?? null;
+ linkAttribution.value = info?.linkAttribution ?? null;
+ // These will be populated by the fetch* functions
+ attributionUser.value = null;
theNote.value = null;
- if (info?.haveNoteLocally) {
- await fetchNote();
- }
+
+ await Promise.all([
+ fetchAttribution(initial),
+ fetchNote(initial),
+ ]);
})
.finally(() => {
fetching.value = null;
@@ -273,7 +345,7 @@ onUnmounted(() => {
});
// Load initial data
-refresh();
+refresh(false, true);
</script>
<style lang="scss" module>
@@ -357,7 +429,7 @@ refresh();
.body {
position: relative;
box-sizing: border-box;
- padding: 16px;
+ padding: 16px !important; // Unfortunately needed to win a specificity race with MkNoteSimple / SkNoteSimple
}
.header {
@@ -395,6 +467,28 @@ refresh();
vertical-align: top;
}
+.linkAttributionIcon {
+ display: inline-block;
+ width: 16px;
+ height: 16px;
+ margin-left: 0.25em;
+ margin-right: 0.25em;
+ vertical-align: middle;
+ border-radius: 50%;
+ * {
+ border-radius: 4px;
+ }
+}
+
+.linkAttribution {
+ width: 100%;
+ font-size: 0.8em;
+ display: inline-block;
+ margin: auto;
+ padding-top: 0.5em;
+ text-align: right;
+}
+
.action {
display: flex;
gap: 6px;
diff --git a/packages/frontend/src/components/SkApprovalUser.vue b/packages/frontend/src/components/SkApprovalUser.vue
index 310d044387..3f5bd345f2 100644
--- a/packages/frontend/src/components/SkApprovalUser.vue
+++ b/packages/frontend/src/components/SkApprovalUser.vue
@@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div>{{ email }}</div>
</div>
<div>
- <div :class="$style.label">Reason</div>
+ <div :class="$style.label">{{ i18n.ts.signupReason }}</div>
<div>{{ reason }}</div>
</div>
</div>
diff --git a/packages/frontend/src/components/SkBadgeStrip.vue b/packages/frontend/src/components/SkBadgeStrip.vue
new file mode 100644
index 0000000000..6611d35b07
--- /dev/null
+++ b/packages/frontend/src/components/SkBadgeStrip.vue
@@ -0,0 +1,84 @@
+<!--
+SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div :class="$style.badges">
+ <div
+ v-for="badge of badges"
+ :key="badge.key"
+ :class="[$style.badge, semanticClass(badge)]"
+ >
+ {{ badge.label }}
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+export interface Badge {
+ /**
+ * ID/key of this badge, must be unique within the strip.
+ */
+ key: string;
+
+ /**
+ * Label text to display.
+ * Should already be translated.
+ */
+ label: string;
+
+ /**
+ * Semantic style of the badge.
+ * Defaults to "neutral" if unset.
+ */
+ style?: 'success' | 'neutral' | 'warning' | 'error';
+}
+</script>
+
+<script setup lang="ts">
+import { useCssModule } from 'vue';
+
+const $style = useCssModule();
+
+defineProps<{
+ badges: Badge[],
+}>();
+
+function semanticClass(badge: Badge): string {
+ const style = badge.style ?? 'neutral';
+ return $style[`semantic_${style}`];
+}
+</script>
+
+<style module lang="scss">
+.badges {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ gap: var(--MI-margin);
+}
+
+.badge {
+ display: inline-block;
+ border: solid 1px;
+ border-radius: var(--MI-radius-sm);
+ padding: 2px 6px;
+ font-size: 85%;
+}
+
+.semantic_error {
+ color: var(--MI_THEME-error);
+ border-color: var(--MI_THEME-error);
+}
+
+.semantic_warning {
+ color: var(--MI_THEME-warn);
+ border-color: var(--MI_THEME-warn);
+}
+
+.semantic_success {
+ color: var(--MI_THEME-success);
+ border-color: var(--MI_THEME-success);
+}
+</style>
diff --git a/packages/frontend/src/components/SkDateSeparatedList.vue b/packages/frontend/src/components/SkDateSeparatedList.vue
new file mode 100644
index 0000000000..239d0c1939
--- /dev/null
+++ b/packages/frontend/src/components/SkDateSeparatedList.vue
@@ -0,0 +1,55 @@
+<!--
+SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div class="_gaps">
+ <template v-for="(item, index) in timeline" :key="item.id">
+ <slot v-if="item.type === 'item'" :id="item.id" :index="index" :item="item.data"></slot>
+ <slot v-else-if="item.type === 'date'" :id="item.id" :index="index" :prev="item.prev" :prevText="item.prevText" :next="item.next" :nextText="item.nextText" name="date">
+ <div :class="$style.dateDivider">
+ <span><i class="ti ti-chevron-up"></i> {{ item.nextText }}</span>
+ <span :class="$style.dateSeparator"></span>
+ <span>{{ item.prevText }} <i class="ti ti-chevron-down"></i></span>
+ </div>
+ </slot>
+ </template>
+</div>
+</template>
+
+<script setup lang="ts" generic="T extends { id: string; createdAt: string; }">
+import { computed } from 'vue';
+import { makeDateSeparatedTimelineComputedRef } from '@/utility/timeline-date-separate';
+
+const props = defineProps<{
+ items: T[],
+}>();
+
+const itemsRef = computed(() => props.items);
+const timeline = makeDateSeparatedTimelineComputedRef(itemsRef);
+</script>
+
+<style module lang="scss">
+// From room.vue
+.dateDivider {
+ display: flex;
+ font-size: 85%;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5em;
+ opacity: 0.75;
+ border: solid 0.5px var(--MI_THEME-divider);
+ border-radius: 999px;
+ width: fit-content;
+ padding: 0.5em 1em;
+ margin: 0 auto;
+}
+
+// From room.vue
+.dateSeparator {
+ height: 1em;
+ width: 1px;
+ background: var(--MI_THEME-divider);
+}
+</style>
diff --git a/packages/frontend/src/components/SkFollowingRecentNotes.vue b/packages/frontend/src/components/SkFollowingRecentNotes.vue
index 3eb2ac8572..37f2f8833a 100644
--- a/packages/frontend/src/components/SkFollowingRecentNotes.vue
+++ b/packages/frontend/src/components/SkFollowingRecentNotes.vue
@@ -14,6 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<template #default="{ items: notes }">
+ <!-- TODO replace with SkDateSeparatedList when merged -->
<MkDateSeparatedList v-slot="{ item: note }" :items="notes" :class="$style.panel" :noGap="true">
<SkFollowingFeedEntry :note="note" :class="props.selectedUserId == note.userId && $style.selected" @select="u => selectUser(u.id)"/>
</MkDateSeparatedList>
diff --git a/packages/frontend/src/components/SkMfmWindow.vue b/packages/frontend/src/components/SkMfmWindow.vue
index 14d309b7ba..c544bc528c 100644
--- a/packages/frontend/src/components/SkMfmWindow.vue
+++ b/packages/frontend/src/components/SkMfmWindow.vue
@@ -100,6 +100,16 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
<div class="section _block">
+ <div class="title">{{ i18n.ts._mfm.unixtime }}</div>
+ <div class="content">
+ <p>{{ i18n.ts._mfm.unixtimeDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_unixtime"/>
+ <MkTextarea v-model="preview_unixtime"><template #label>MFM</template></MkTextarea>
+ </div>
+ </div>
+ </div>
+ <div class="section _block">
<div class="title">{{ i18n.ts._mfm.inlineCode }}</div>
<div class="content">
<p>{{ i18n.ts._mfm.inlineCodeDescription }}</p>
@@ -429,6 +439,9 @@ const preview_small = ref(
const preview_center = ref(
`<center>${i18n.ts._mfm.dummy}</center>`,
);
+const preview_unixtime = ref(
+ `$[unixtime ${Math.floor(Date.now() / 1000)}]`,
+);
const preview_inlineCode = ref('`<: "Hello, world!"`');
const preview_blockCode = ref(
'```ai\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```',
diff --git a/packages/frontend/src/components/SkMutedNote.vue b/packages/frontend/src/components/SkMutedNote.vue
index 3c072fab3f..c9b3d768de 100644
--- a/packages/frontend/src/components/SkMutedNote.vue
+++ b/packages/frontend/src/components/SkMutedNote.vue
@@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkUserName :user="note.user"/>
</template>
</I18n>
-<I18n v-else-if="prefer.s.showSoftWordMutedWord" :src="i18n.ts.userSaysSomething" tag="small">
+<I18n v-else-if="!prefer.s.showSoftWordMutedWord" :src="i18n.ts.userSaysSomething" tag="small">
<template #name>
<MkUserName :user="note.user"/>
</template>
diff --git a/packages/frontend/src/components/SkNote.vue b/packages/frontend/src/components/SkNote.vue
index 5184cbd801..4d6d080ddf 100644
--- a/packages/frontend/src/components/SkNote.vue
+++ b/packages/frontend/src/components/SkNote.vue
@@ -97,7 +97,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :author="appearNote.user" :emojiUrls="appearNote.emojis" :class="$style.poll" @click.stop/>
<div v-if="isEnabledUrlPreview">
- <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" :class="$style.urlPreview" @click.stop/>
+ <SkUrlPreviewGroup :sourceUrls="urls" :sourceNote="appearNote" :compact="true" :detail="false" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" :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">
@@ -114,7 +114,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkA :to="`/notes/${appearNote.id}/reactions`" :class="[$style.reactionOmitted]">{{ i18n.ts.more }}</MkA>
</template>
</MkReactionsViewer>
- <footer :class="$style.footer">
+ <footer :class="$style.footer" class="_gaps _h_gaps" tabindex="0" role="group" :aria-label="i18n.ts.noteFooterLabel">
<button :class="$style.footerButton" class="_button" @click.stop @click="reply()">
<i class="ti ti-arrow-back-up"></i>
<p v-if="appearNote.repliesCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.repliesCount) }}</p>
@@ -158,7 +158,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @click.stop="clip()">
<i class="ti ti-paperclip"></i>
</button>
- <button v-if="prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable" class="_button" :class="$style.footerButton" :disabled="translating || !!translation" @click.stop="translate()">
+ <button v-if="prefer.s.showTranslationButtonInNoteFooter && policies.canUseTranslator && instance.translatorAvailable" class="_button" :class="$style.footerButton" :disabled="translating || !!translation" @click.stop="translate()">
<i class="ti ti-language-hiragana"></i>
</button>
<button ref="menuButton" :class="$style.footerButton" class="_button" @click.stop="showMenu()">
@@ -181,7 +181,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, inject, onMounted, ref, useTemplateRef, watch, provide } from 'vue';
-import * as mfm from '@transfem-org/sfm-js';
+import * as mfm from 'mfm-js';
import * as Misskey from 'misskey-js';
import { isLink } from '@@/js/is-link.js';
import { shouldCollapsed } from '@@/js/collapsed.js';
@@ -226,7 +226,7 @@ import { getNoteSummary } from '@/utility/get-note-summary.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { showMovedDialog } from '@/utility/show-moved-dialog.js';
import { boostMenuItems, computeRenoteTooltip } from '@/utility/boost-quote.js';
-import { instance, isEnabledUrlPreview } from '@/instance.js';
+import { instance, isEnabledUrlPreview, policies } from '@/instance.js';
import { focusPrev, focusNext } from '@/utility/focus.js';
import { getAppearNote } from '@/utility/get-appear-note.js';
import { prefer } from '@/preferences.js';
@@ -237,6 +237,7 @@ import SkMutedNote from '@/components/SkMutedNote.vue';
import SkNoteTranslation from '@/components/SkNoteTranslation.vue';
import { getSelfNoteIds } from '@/utility/get-self-note-ids.js';
import { extractPreviewUrls } from '@/utility/extract-preview-urls.js';
+import SkUrlPreviewGroup from '@/components/SkUrlPreviewGroup.vue';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
@@ -304,9 +305,9 @@ const galleryEl = useTemplateRef('galleryEl');
const isMyRenote = $i && ($i.id === note.value.userId);
const showContent = ref(prefer.s.uncollapseCW);
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null);
-const urls = computed(() => parsed.value ? extractPreviewUrls(props.note, parsed.value) : null);
+const urls = computed(() => parsed.value ? extractPreviewUrls(appearNote.value, parsed.value) : []);
const selfNoteIds = computed(() => getSelfNoteIds(props.note));
-const isLong = shouldCollapsed(appearNote.value, urls.value ?? []);
+const isLong = shouldCollapsed(appearNote.value, urls.value);
const collapsed = ref(prefer.s.expandLongNote && appearNote.value.cw == null && isLong ? false : appearNote.value.cw == null && isLong);
const isDeleted = ref(false);
const renoted = ref(false);
@@ -360,7 +361,7 @@ const keymap = {
clip();
},
't': () => {
- if (prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable) {
+ if (prefer.s.showTranslationButtonInNoteFooter && policies.value.canUseTranslator && instance.translatorAvailable) {
translate();
}
},
@@ -921,11 +922,11 @@ function emitUpdReaction(emoji: string, delta: number) {
.footer {
display: flex;
align-items: center;
- justify-content: space-between;
+ justify-content: flex-start;
position: relative;
z-index: 1;
margin-top: 0.4em;
- max-width: 400px;
+ overflow-x: auto;
}
&:hover > .article > .main > .footer > .footerButton {
@@ -947,10 +948,6 @@ function emitUpdReaction(emoji: string, delta: number) {
.footerButton {
font-size: 90%;
-
- &:not(:last-child) {
- margin-right: 0;
- }
}
}
@@ -1238,10 +1235,6 @@ function emitUpdReaction(emoji: string, delta: number) {
padding: 8px;
opacity: 0.7;
- &:not(:last-child) {
- margin-right: 1.5em;
- }
-
&:hover {
color: var(--MI_THEME-fgHighlighted);
}
@@ -1358,25 +1351,7 @@ function emitUpdReaction(emoji: string, delta: number) {
}
}
-@container (max-width: 400px) {
- .root:not(.showActionsOnlyHover) {
- .footerButton {
- &:not(:last-child) {
- margin-right: 0.2em;
- }
- }
- }
-}
-
@container (max-width: 350px) {
- .root:not(.showActionsOnlyHover) {
- .footerButton {
- &:not(:last-child) {
- margin-right: 0.1em;
- }
- }
- }
-
.colorBar {
top: 6px;
left: 6px;
@@ -1385,16 +1360,6 @@ function emitUpdReaction(emoji: string, delta: number) {
}
}
-@container (max-width: 300px) {
- .root:not(.showActionsOnlyHover) {
- .footerButton {
- &:not(:last-child) {
- margin-right: 0.1em;
- }
- }
- }
-}
-
@container (max-width: 250px) {
.quoteNote {
padding: 12px;
diff --git a/packages/frontend/src/components/SkNoteDetailed.vue b/packages/frontend/src/components/SkNoteDetailed.vue
index b165b95d40..f761029cfb 100644
--- a/packages/frontend/src/components/SkNoteDetailed.vue
+++ b/packages/frontend/src/components/SkNoteDetailed.vue
@@ -108,7 +108,6 @@ SPDX-License-Identifier: AGPL-3.0-only
:isBlock="true"
class="_selectable"
/>
- <a v-if="appearNote.renote != null" :class="$style.rn">RN:</a>
<SkNoteTranslation :note="note" :translation="translation" :translating="translating"></SkNoteTranslation>
<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>
<MkButton v-else-if="!prefer.s.animatedMfm && allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton>
@@ -117,7 +116,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :class="$style.poll" :author="appearNote.user" :emojiUrls="appearNote.emojis"/>
<div v-if="isEnabledUrlPreview">
- <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" style="margin-top: 6px;"/>
+ <SkUrlPreviewGroup :sourceNodes="nodes" :sourceNote="appearNote" :compact="true" :detail="true" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" style="margin-top: 6px;" @click.stop/>
</div>
<div v-if="appearNote.renote" :class="$style.quote"><SkNoteSimple :note="appearNote.renote" :class="$style.quoteNote" :expandAllCws="props.expandAllCws"/></div>
</div>
@@ -132,7 +131,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkA>
</div>
<MkReactionsViewer v-if="appearNote.reactionAcceptance !== 'likeOnly'" ref="reactionsViewer" style="margin-top: 6px;" :note="appearNote"/>
- <footer :class="$style.footer">
+ <footer :class="$style.footer" class="_gaps _h_gaps" tabindex="0" role="group" :aria-label="i18n.ts.noteFooterLabel">
<button class="_button" :class="$style.noteFooterButton" @click="reply()">
<i class="ti ti-arrow-back-up"></i>
<p v-if="appearNote.repliesCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.repliesCount) }}</p>
@@ -174,7 +173,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @click.stop="clip()">
<i class="ti ti-paperclip"></i>
</button>
- <button v-if="prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable" ref="translationButton" class="_button" :class="$style.noteFooterButton" :disabled="translating || !!translation" @click.stop="translate()">
+ <button v-if="prefer.s.showTranslationButtonInNoteFooter && policies.canUseTranslator && instance.translatorAvailable" ref="translationButton" class="_button" :class="$style.noteFooterButton" :disabled="translating || !!translation" @click.stop="translate()">
<i class="ti ti-language-hiragana"></i>
</button>
<button ref="menuButton" class="_button" :class="$style.noteFooterButton" @click.stop="showMenu()">
@@ -238,7 +237,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, inject, onMounted, onUnmounted, onUpdated, provide, ref, useTemplateRef, watch } from 'vue';
-import * as mfm from '@transfem-org/sfm-js';
+import * as mfm from 'mfm-js';
import * as Misskey from 'misskey-js';
import { isLink } from '@@/js/is-link.js';
import * as config from '@@/js/config.js';
@@ -283,7 +282,7 @@ import MkPagination from '@/components/MkPagination.vue';
import MkReactionIcon from '@/components/MkReactionIcon.vue';
import MkButton from '@/components/MkButton.vue';
import { boostMenuItems, computeRenoteTooltip } from '@/utility/boost-quote.js';
-import { instance, isEnabledUrlPreview } from '@/instance.js';
+import { instance, isEnabledUrlPreview, policies } from '@/instance.js';
import { getAppearNote } from '@/utility/get-appear-note.js';
import { prefer } from '@/preferences.js';
import { getPluginHandlers } from '@/plugin.js';
@@ -291,7 +290,7 @@ import { DI } from '@/di.js';
import SkMutedNote from '@/components/SkMutedNote.vue';
import SkNoteTranslation from '@/components/SkNoteTranslation.vue';
import { getSelfNoteIds } from '@/utility/get-self-note-ids.js';
-import { extractPreviewUrls } from '@/utility/extract-preview-urls';
+import SkUrlPreviewGroup from '@/components/SkUrlPreviewGroup.vue';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
@@ -345,8 +344,7 @@ const isDeleted = ref(false);
const renoted = ref(false);
const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null);
const translating = ref(false);
-const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null);
-const urls = computed(() => parsed.value ? extractPreviewUrls(props.note, parsed.value) : null);
+const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : []);
const selfNoteIds = computed(() => getSelfNoteIds(props.note));
const animated = computed(() => parsed.value ? checkAnimationFromMfm(parsed.value) : null);
const allowAnim = ref(prefer.s.advancedMfm && prefer.s.animatedMfm ? true : false);
@@ -394,7 +392,7 @@ const keymap = {
clip();
},
't': () => {
- if (prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable) {
+ if (prefer.s.showTranslationButtonInNoteFooter && policies.value.canUseTranslator && instance.translatorAvailable) {
translate();
}
},
@@ -421,6 +419,11 @@ provide(DI.mfmEmojiReactCallback, (reaction) => {
const tab = ref(props.initialTab);
const reactionTabType = ref<string | null>(null);
+// Auto-select the first page of reactions
+watch(appearNote, n => {
+ reactionTabType.value ??= Object.keys(n.reactions)[0] ?? null;
+}, { immediate: true });
+
const renotesPagination = computed<Paging>(() => ({
endpoint: 'notes/renotes',
limit: 10,
@@ -918,13 +921,13 @@ onUnmounted(() => {
}
.footer {
- display: flex;
- align-items: center;
- justify-content: space-between;
- position: relative;
- z-index: 1;
- margin-top: 0.4em;
- max-width: 400px;
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ position: relative;
+ z-index: 1;
+ margin-top: 0.4em;
+ overflow-x: auto;
}
.replyTo {
@@ -1141,10 +1144,6 @@ onUnmounted(() => {
padding: 8px;
opacity: 0.7;
- &:not(:last-child) {
- margin-right: 1.5em;
- }
-
&:hover {
color: var(--MI_THEME-fgHighlighted);
}
@@ -1234,14 +1233,6 @@ onUnmounted(() => {
}
}
-@container (max-width: 350px) {
- .noteFooterButton {
- &:not(:last-child) {
- margin-right: 0.1em;
- }
- }
-}
-
@container (max-width: 300px) {
.root {
font-size: 0.825em;
@@ -1251,12 +1242,6 @@ onUnmounted(() => {
width: 50px;
height: 50px;
}
-
- .noteFooterButton {
- &:not(:last-child) {
- margin-right: 0.1em;
- }
- }
}
.avatar {
diff --git a/packages/frontend/src/components/SkNoteSub.vue b/packages/frontend/src/components/SkNoteSub.vue
index 775436cb0f..4e8a3147ad 100644
--- a/packages/frontend/src/components/SkNoteSub.vue
+++ b/packages/frontend/src/components/SkNoteSub.vue
@@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
<MkReactionsViewer ref="reactionsViewer" :note="note"/>
- <footer :class="$style.footer">
+ <footer :class="$style.footer" class="_gaps _h_gaps" tabindex="0" role="group" :aria-label="i18n.ts.noteFooterLabel">
<button class="_button" :class="$style.noteFooterButton" @click="reply()">
<i class="ph-arrow-u-up-left ph-bold ph-lg"></i>
<p v-if="note.repliesCount > 0" :class="$style.noteFooterButtonCount">{{ note.repliesCount }}</p>
@@ -70,7 +70,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" :class="$style.noteFooterButton" class="_button" @click.stop="clip()">
<i class="ti ti-paperclip"></i>
</button>
- <button v-if="prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable" ref="translationButton" class="_button" :class="$style.noteFooterButton" :disabled="translating || !!translation" @click.stop="translate()">
+ <button v-if="prefer.s.showTranslationButtonInNoteFooter && policies.canUseTranslator && instance.translatorAvailable" ref="translationButton" class="_button" :class="$style.noteFooterButton" :disabled="translating || !!translation" @click.stop="translate()">
<i class="ti ti-language-hiragana"></i>
</button>
<button ref="menuButton" class="_button" :class="$style.noteFooterButton" @click.stop="menu()">
@@ -121,7 +121,8 @@ import { boostMenuItems, computeRenoteTooltip } from '@/utility/boost-quote.js';
import { prefer } from '@/preferences.js';
import { useNoteCapture } from '@/use/use-note-capture.js';
import SkMutedNote from '@/components/SkMutedNote.vue';
-import { instance } from '@/instance';
+import { instance, policies } from '@/instance';
+import { getAppearNote } from '@/utility/get-appear-note';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
@@ -141,7 +142,9 @@ const props = withDefaults(defineProps<{
onDeleteCallback: undefined,
});
-const canRenote = computed(() => ['public', 'home'].includes(props.note.visibility) || props.note.userId === $i?.id);
+const appearNote = computed(() => getAppearNote(props.note));
+
+const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || appearNote.value.userId === $i?.id);
const hideLine = computed(() => props.detail);
const el = shallowRef<HTMLElement>();
@@ -158,19 +161,11 @@ const likeButton = shallowRef<HTMLElement>();
const renoteTooltip = computeRenoteTooltip(renoted);
-let appearNote = computed(() => isRenote ? props.note.renote as Misskey.entities.Note : props.note);
const defaultLike = computed(() => prefer.s.like ? prefer.s.like : null);
const replies = ref<Misskey.entities.Note[]>([]);
const mergedCW = computed(() => computeMergedCw(appearNote.value));
-const isRenote = (
- props.note.renote != null &&
- props.note.text == null &&
- props.note.fileIds && props.note.fileIds.length === 0 &&
- props.note.poll == null
-);
-
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
type: 'lookup',
url: appearNote.value.url ?? appearNote.value.uri ?? `${config.url}/notes/${appearNote.value.id}`,
@@ -220,8 +215,8 @@ async function reply(viaKeyboard = false): Promise<void> {
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
showMovedDialog();
await os.post({
- reply: props.note,
- channel: props.note.channel ?? undefined,
+ reply: appearNote.value,
+ channel: appearNote.value.channel ?? undefined,
animation: !viaKeyboard,
});
focus();
@@ -231,9 +226,9 @@ function react(): void {
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
showMovedDialog();
sound.playMisskeySfx('reaction');
- if (props.note.reactionAcceptance === 'likeOnly') {
+ if (appearNote.value.reactionAcceptance === 'likeOnly') {
misskeyApi('notes/like', {
- noteId: props.note.id,
+ noteId: appearNote.value.id,
override: defaultLike.value,
});
const el = reactButton.value as HTMLElement | null | undefined;
@@ -247,12 +242,12 @@ function react(): void {
}
} else {
blur();
- reactionPicker.show(reactButton.value ?? null, props.note, reaction => {
+ reactionPicker.show(reactButton.value ?? null, appearNote.value, reaction => {
misskeyApi('notes/reactions/create', {
- noteId: props.note.id,
+ noteId: appearNote.value.id,
reaction: reaction,
});
- if (props.note.text && props.note.text.length > 100 && (Date.now() - new Date(props.note.createdAt).getTime() < 1000 * 3)) {
+ if (appearNote.value.text && appearNote.value.text.length > 100 && (Date.now() - new Date(appearNote.value.createdAt).getTime() < 1000 * 3)) {
claimAchievement('reactWithoutRead');
}
}, () => {
@@ -266,7 +261,7 @@ function like(): void {
showMovedDialog();
sound.playMisskeySfx('reaction');
misskeyApi('notes/like', {
- noteId: props.note.id,
+ noteId: appearNote.value.id,
override: defaultLike.value,
});
const el = likeButton.value as HTMLElement | null | undefined;
@@ -375,7 +370,7 @@ function quote() {
}).then((cancelled) => {
if (cancelled) return;
misskeyApi('notes/renotes', {
- noteId: props.note.id,
+ noteId: appearNote.value.id,
userId: $i?.id,
limit: 1,
quote: true,
@@ -397,12 +392,12 @@ function quote() {
}
function menu(): void {
- const { menu, cleanup } = getNoteMenu({ note: props.note, translating, translation, isDeleted });
+ const { menu, cleanup } = getNoteMenu({ note: appearNote.value, translating, translation, isDeleted });
os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup);
}
async function clip(): Promise<void> {
- os.popupMenu(await getNoteClipMenu({ note: props.note, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus);
+ os.popupMenu(await getNoteClipMenu({ note: appearNote.value, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus);
}
async function translate() {
@@ -411,7 +406,7 @@ async function translate() {
if (props.detail) {
misskeyApi('notes/children', {
- noteId: props.note.id,
+ noteId: appearNote.value.id,
limit: prefer.s.numberOfReplies,
showQuotes: false,
}).then(res => {
@@ -449,11 +444,11 @@ if (props.detail) {
.footer {
display: flex;
align-items: center;
- justify-content: space-between;
+ justify-content: flex-start;
position: relative;
z-index: 1;
margin-top: 0.4em;
- max-width: 400px;
+ overflow-x: auto;
}
.main {
@@ -559,14 +554,6 @@ if (props.detail) {
}
}
-@container (max-width: 400px) {
- .noteFooterButton {
- &:not(:last-child) {
- margin-right: 0.7em;
- }
- }
-}
-
.noteFooterButtonCount {
display: inline;
margin: 0 0 0 8px;
diff --git a/packages/frontend/src/components/SkNoteTranslation.vue b/packages/frontend/src/components/SkNoteTranslation.vue
index 170eea80cf..406242f1d1 100644
--- a/packages/frontend/src/components/SkNoteTranslation.vue
+++ b/packages/frontend/src/components/SkNoteTranslation.vue
@@ -33,7 +33,6 @@ if (_DEV_) {
watch(
[() => props.translation, () => props.translating],
([translation, translating]) => console.debug('Translation status changed: ', { translation, translating }),
- { immediate: true },
);
}
</script>
diff --git a/packages/frontend/src/components/SkOldNoteWindow.vue b/packages/frontend/src/components/SkOldNoteWindow.vue
index b6dbec81c5..aa1da2d6e3 100644
--- a/packages/frontend/src/components/SkOldNoteWindow.vue
+++ b/packages/frontend/src/components/SkOldNoteWindow.vue
@@ -40,19 +40,19 @@ SPDX-License-Identifier: AGPL-3.0-only
<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" :isBlock="true" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/>
+ <Mfm v-if="appearNote.text" :text="appearNote.text" :parsedNodes="parsed" :isBlock="true" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/>
<a v-if="appearNote.renote != null" :class="$style.rn">RN:</a>
<SkNoteTranslation :note="note" :translation="translation" :translating="translating"></SkNoteTranslation>
<div v-if="appearNote.files && appearNote.files.length > 0">
<MkMediaList :mediaList="appearNote.files"/>
</div>
<MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :author="appearNote.user" :emojiUrls="appearNote.emojis" :class="$style.poll"/>
- <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" style="margin-top: 6px;"/>
+ <SkUrlPreviewGroup :sourceNodes="parsed" :sourceNote="appearNote" :compact="true" :detail="true" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" style="margin-top: 6px;" @click.stop/>
<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
</div>
<MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ph-television ph-bold ph-lg"></i> {{ appearNote.channel.name }}</MkA>
</div>
- <footer :class="$style.footer">
+ <footer :class="$style.footer" class="_gaps _h_gaps" tabindex="0" role="group" :aria-label="i18n.ts.noteFooterLabel">
<div :class="$style.noteFooterInfo">
<MkTime :time="appearNote.createdAt" mode="detail"/>
</div>
@@ -76,14 +76,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { inject, onMounted, ref, shallowRef, computed } from 'vue';
-import * as mfm from '@transfem-org/sfm-js';
+import * as mfm from 'mfm-js';
import * as Misskey from 'misskey-js';
import MkNoteSimple from '@/components/MkNoteSimple.vue';
import MkMediaList from '@/components/MkMediaList.vue';
import MkCwButton from '@/components/MkCwButton.vue';
import MkWindow from '@/components/MkWindow.vue';
import MkPoll from '@/components/MkPoll.vue';
-import MkUrlPreview from '@/components/MkUrlPreview.vue';
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
import { userPage } from '@/filters/user.js';
import { i18n } from '@/i18n.js';
@@ -93,7 +92,7 @@ import { prefer } from '@/preferences';
import { getPluginHandlers } from '@/plugin.js';
import SkNoteTranslation from '@/components/SkNoteTranslation.vue';
import { getSelfNoteIds } from '@/utility/get-self-note-ids';
-import { extractPreviewUrls } from '@/utility/extract-preview-urls.js';
+import SkUrlPreviewGroup from '@/components/SkUrlPreviewGroup.vue';
const props = defineProps<{
note: Misskey.entities.Note;
@@ -143,12 +142,11 @@ const isRenote = (
const el = shallowRef<HTMLElement>();
const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value);
-const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null);
+const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : []);
const showContent = ref(false);
const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null);
const translating = ref(false);
-const urls = computed(() => parsed.value ? extractPreviewUrls(props.note, parsed.value) : null);
const selfNoteIds = computed(() => getSelfNoteIds(props.note));
const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance);
@@ -163,11 +161,12 @@ const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceT
}
.footer {
- position: relative;
- z-index: 1;
- margin-top: 0.4em;
- width: max-content;
- min-width: max-content;
+ position: relative;
+ z-index: 1;
+ margin-top: 0.4em;
+ width: max-content;
+ min-width: max-content;
+ overflow-x: auto;
}
.note {
@@ -280,23 +279,11 @@ const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceT
padding: 8px;
opacity: 0.7;
- &:not(:last-child) {
- margin-right: 1.5em;
- }
-
&:hover {
color: var(--MI_THEME-fgHighlighted);
}
}
-@container (max-width: 350px) {
- .noteFooterButton {
- &:not(:last-child) {
- margin-right: 0.1em;
- }
- }
-}
-
@container (max-width: 500px) {
.root {
font-size: 0.9em;
@@ -323,11 +310,5 @@ const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceT
width: 50px;
height: 50px;
}
-
- .noteFooterButton {
- &:not(:last-child) {
- margin-right: 0.1em;
- }
- }
}
</style>
diff --git a/packages/frontend/src/components/SkTransitionGroup.vue b/packages/frontend/src/components/SkTransitionGroup.vue
new file mode 100644
index 0000000000..1c07186501
--- /dev/null
+++ b/packages/frontend/src/components/SkTransitionGroup.vue
@@ -0,0 +1,43 @@
+<!--
+SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<TransitionGroup v-if="animate ?? prefer.s.animation" v-bind="props" :class="props.class">
+ <slot></slot>
+</TransitionGroup>
+<component :is="tag" v-else :class="props.class">
+ <slot></slot>
+</component>
+</template>
+
+<script setup lang="ts">
+import type { TransitionGroupProps } from 'vue';
+import { prefer } from '@/preferences';
+
+// This is a "best guess" type.
+// If any valid :class binding produces a type error here, then please change this to match.
+type ClassBinding = string | Record<string, boolean | undefined>;
+
+// This can be an inline type, but pulling it out makes TS errors clearer.
+interface SkTransitionGroupProps extends TransitionGroupProps {
+ /**
+ * Override CSS styles for the TransitionGroup or native element.
+ */
+ class?: undefined | ClassBinding | ClassBinding[];
+
+ /**
+ * If true, will render a TransitionGroup.
+ * If false, will render a native element.
+ * If null or undefined (default), will respect the value of prefer.s.animation.
+ */
+ animate?: boolean | undefined | null;
+}
+
+const props = withDefaults(defineProps<SkTransitionGroupProps>(), {
+ tag: 'div',
+ class: undefined,
+ animate: undefined,
+});
+</script>
diff --git a/packages/frontend/src/components/SkUrlPreviewGroup.vue b/packages/frontend/src/components/SkUrlPreviewGroup.vue
new file mode 100644
index 0000000000..dbd930248a
--- /dev/null
+++ b/packages/frontend/src/components/SkUrlPreviewGroup.vue
@@ -0,0 +1,348 @@
+<!--
+SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div v-if="isRefreshing">
+ <MkLoading :class="$style.loading"></MkLoading>
+</div>
+<template v-else>
+ <MkUrlPreview
+ v-for="preview of urlPreviews"
+ :key="preview.url"
+ :url="preview.url"
+ :previewHint="preview"
+ :noteHint="preview.note"
+ :attributionHint="preview.attributionUser"
+ :detail="detail"
+ :compact="compact"
+ :showAsQuote="showAsQuote"
+ :showActions="showActions"
+ :skipNoteIds="skipNoteIds"
+ ></MkUrlPreview>
+</template>
+</template>
+
+<script setup lang="ts">
+import * as Misskey from 'misskey-js';
+import * as mfm from 'mfm-js';
+import { computed, ref, watch } from 'vue';
+import { versatileLang } from '@@/js/intl-const';
+import promiseLimit from 'promise-limit';
+import type { SummalyResult } from '@/components/MkUrlPreview.vue';
+import { extractPreviewUrls } from '@/utility/extract-preview-urls';
+import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm';
+import { $i } from '@/i';
+import { misskeyApi } from '@/utility/misskey-api';
+import MkUrlPreview from '@/components/MkUrlPreview.vue';
+import { getNoteUrls } from '@/utility/getNoteUrls';
+
+type Summary = SummalyResult & {
+ note?: Misskey.entities.Note | null;
+ attributionUser?: Misskey.entities.User | null;
+};
+
+type Limiter<T> = ReturnType<typeof promiseLimit<T>>;
+
+const props = withDefaults(defineProps<{
+ sourceUrls?: string[];
+ sourceNodes?: mfm.MfmNode[];
+ sourceText?: string;
+ sourceNote?: Misskey.entities.Note;
+
+ detail?: boolean;
+ compact?: boolean;
+ showAsQuote?: boolean;
+ showActions?: boolean;
+ skipNoteIds?: string[];
+}>(), {
+ sourceUrls: undefined,
+ sourceText: undefined,
+ sourceNodes: undefined,
+ sourceNote: undefined,
+
+ detail: undefined,
+ compact: undefined,
+ showAsQuote: undefined,
+ showActions: undefined,
+ skipNoteIds: () => [],
+});
+
+const urlPreviews = ref<Summary[]>([]);
+
+const urls = computed<string[]>(() => {
+ if (props.sourceUrls) {
+ return props.sourceUrls;
+ }
+
+ // sourceNodes > sourceText > sourceNote
+ const source =
+ props.sourceNodes ??
+ (props.sourceText ? mfm.parse(props.sourceText) : null) ??
+ (props.sourceNote?.text ? mfm.parse(props.sourceNote.text) : null);
+
+ if (source) {
+ if (props.sourceNote) {
+ return extractPreviewUrls(props.sourceNote, source);
+ } else {
+ return extractUrlFromMfm(source);
+ }
+ }
+
+ return [];
+});
+
+// todo un-ref these
+const isRefreshing = ref<Promise<void> | false>(false);
+const cachedNotes = ref(new Map<string, Misskey.entities.Note | null>());
+const cachedPreviews = ref(new Map<string, Summary | null>());
+const cachedUsers = new Map<string, Misskey.entities.User | null>();
+
+/**
+ * Refreshes the group.
+ * Calls are automatically de-duplicated.
+ */
+function refresh(): Promise<void> {
+ if (isRefreshing.value) return isRefreshing.value;
+
+ const promise = doRefresh();
+ promise.finally(() => isRefreshing.value = false);
+ isRefreshing.value = promise;
+ return promise;
+}
+
+/**
+ * Refreshes the group.
+ * Don't call this directly - use refresh() instead!
+ */
+async function doRefresh(): Promise<void> {
+ let previews = await fetchPreviews();
+
+ // Remove duplicates
+ previews = deduplicatePreviews(previews);
+
+ // Remove any with hidden notes
+ previews = previews.filter(preview => !preview.note || !props.skipNoteIds.includes(preview.note.id));
+
+ urlPreviews.value = previews;
+}
+
+async function fetchPreviews(): Promise<Summary[]> {
+ const userLimiter = promiseLimit<Misskey.entities.User | null>(4);
+ const noteLimiter = promiseLimit<Misskey.entities.Note | null>(2);
+ const summaryLimiter = promiseLimit<Summary | null>(5);
+
+ const summaries = await Promise.all(urls.value.map(url =>
+ summaryLimiter(async () => {
+ return await fetchPreview(url);
+ }).then(async (summary) => {
+ if (summary) {
+ await Promise.all([
+ attachNote(summary, noteLimiter),
+ attachAttribution(summary, userLimiter),
+ ]);
+ }
+
+ return summary;
+ })));
+
+ return summaries.filter((preview): preview is Summary => preview != null);
+}
+
+async function fetchPreview(url: string): Promise<Summary | null> {
+ const cached = cachedPreviews.value.get(url);
+ if (cached) {
+ return cached;
+ }
+
+ const headers = $i ? { Authorization: `Bearer ${$i.token}` } : undefined;
+ const params = new URLSearchParams({ url, lang: versatileLang });
+ const res = await window.fetch(`/url?${params.toString()}`, { headers }).catch(() => null);
+
+ if (res?.ok) {
+ // Success - got the summary
+ const summary: Summary = await res.json();
+ cachedPreviews.value.set(url, summary);
+ if (summary.url !== url) {
+ cachedPreviews.value.set(summary.url, summary);
+ }
+ return summary;
+ }
+
+ // Failed, blocked, or not found
+ cachedPreviews.value.set(url, null);
+ return null;
+}
+
+async function attachNote(summary: Summary, noteLimiter: Limiter<Misskey.entities.Note | null>): Promise<void> {
+ if (props.showAsQuote && summary.activityPub && summary.haveNoteLocally) {
+ // Have to pull this out to make TS happy
+ const noteUri = summary.activityPub;
+
+ summary.note = await noteLimiter(async () => {
+ return await fetchNote(noteUri);
+ });
+ }
+}
+
+async function fetchNote(noteUri: string): Promise<Misskey.entities.Note | null> {
+ const cached = cachedNotes.value.get(noteUri);
+ if (cached) {
+ return cached;
+ }
+
+ const response = await misskeyApi('ap/show', { uri: noteUri }).catch(() => null);
+ if (response && response.type === 'Note') {
+ const note = response['object'];
+
+ // Success - got the note
+ cachedNotes.value.set(noteUri, note);
+ if (note.uri && note.uri !== noteUri) {
+ cachedNotes.value.set(note.uri, note);
+ }
+ return note;
+ }
+
+ // Failed, blocked, or not found
+ cachedNotes.value.set(noteUri, null);
+ return null;
+}
+
+async function attachAttribution(summary: Summary, userLimiter: Limiter<Misskey.entities.User | null>): Promise<void> {
+ if (summary.linkAttribution) {
+ // Have to pull this out to make TS happy
+ const userId = summary.linkAttribution.userId;
+
+ summary.attributionUser = await userLimiter(async () => {
+ return await fetchUser(userId);
+ });
+ }
+}
+
+async function fetchUser(userId: string): Promise<Misskey.entities.User | null> {
+ const cached = cachedUsers.get(userId);
+ if (cached) {
+ return cached;
+ }
+
+ const user = await misskeyApi('users/show', { userId }).catch(() => null);
+
+ cachedUsers.set(userId, user);
+ return user;
+}
+
+function deduplicatePreviews(previews: Summary[]): Summary[] {
+ // eslint-disable-next-line no-param-reassign
+ previews = previews
+ // Remove any previews with duplicate URL
+ .filter((preview, index) => !previews.some((p, i) => {
+ // Skip the current preview (don't count self as duplicate).
+ if (p === preview) return false;
+
+ // Skip differing URLs (not duplicate).
+ if (p.url !== preview.url) return false;
+
+ // Skip if we have AP and the other doesn't
+ if (preview.activityPub && !p.activityPub) return false;
+
+ // Skip if we have a note and the other doesn't
+ if (preview.note && !p.note) return false;
+
+ // Skip later previews (keep the earliest instance)...
+ // ...but only if we have AP or the later one doesn't...
+ // ...and only if we have note or the later one doesn't.
+ if (i > index && (preview.activityPub || !p.activityPub) && (preview.note || !p.note)) return false;
+
+ // If we get here, then "preview" is a duplicate of "p" and should be skipped.
+ return true;
+ }));
+
+ // eslint-disable-next-line no-param-reassign
+ previews = previews
+ // Remove any previews with duplicate AP
+ .filter((preview, index) => !previews.some((p, i) => {
+ // Skip the current preview (don't count self as duplicate).
+ if (p === preview) return false;
+
+ // Skip if we don't have AP
+ if (!preview.activityPub) return false;
+
+ // Skip if other does not have AP
+ if (!p.activityPub) return false;
+
+ // Skip differing URLs (not duplicate).
+ if (p.activityPub !== preview.activityPub) return false;
+
+ // Skip later previews (keep the earliest instance)
+ if (i > index) return false;
+
+ // If we get here, then "preview" is a duplicate of "p" and should be skipped.
+ return true;
+ }));
+
+ // eslint-disable-next-line no-param-reassign
+ previews = previews
+ // Remove any previews with duplicate note
+ .filter((preview, index) => !previews.some((p, i) => {
+ // Skip the current preview (don't count self as duplicate).
+ if (p === preview) return false;
+
+ // Skip if we don't have a note
+ if (!preview.note) return false;
+
+ // Skip if other does not have a note
+ if (!p.note) return false;
+
+ // Skip differing notes (not duplicate).
+ if (p.note.id !== preview.note.id) return false;
+
+ // Skip later previews (keep the earliest instance)
+ if (i > index) return false;
+
+ // If we get here, then "preview" is a duplicate of "p" and should be skipped.
+ return true;
+ }));
+
+ // eslint-disable-next-line no-param-reassign
+ previews = previews
+ // Remove any previews where the note duplicates url
+ .filter((preview, index) => !previews.some((p, i) => {
+ // Skip the current preview (don't count self as duplicate).
+ if (p === preview) return false;
+
+ // Skip if we have a note
+ if (preview.note) return false;
+
+ // Skip if other does not have a note
+ if (!p.note) return false;
+
+ // Skip later previews (keep the earliest instance)
+ if (i > index) return false;
+
+ const noteUrls = getNoteUrls(p.note);
+
+ // Remove if other duplicates our AP URL
+ if (preview.activityPub && noteUrls.includes(preview.activityPub)) return true;
+
+ // Remove if other duplicates our main URL
+ return noteUrls.includes(preview.url);
+ }));
+
+ return previews;
+}
+
+// Kick everything off, and watch for changes.
+watch(
+ [urls, () => props.showAsQuote, () => props.skipNoteIds],
+ () => refresh(),
+ { immediate: true },
+);
+</script>
+
+<style module lang="scss">
+.loading {
+ box-shadow: 0 0 0 1px var(--MI_THEME-divider);
+ border-radius: var(--MI-radius-sm);
+}
+</style>
diff --git a/packages/frontend/src/components/global/MkMfm.ts b/packages/frontend/src/components/global/MkMfm.ts
index dea486e66d..9f92c43b68 100644
--- a/packages/frontend/src/components/global/MkMfm.ts
+++ b/packages/frontend/src/components/global/MkMfm.ts
@@ -4,7 +4,7 @@
*/
import { h, defineAsyncComponent } from 'vue';
-import * as mfm from '@transfem-org/sfm-js';
+import * as mfm from 'mfm-js';
import * as Misskey from 'misskey-js';
import { host } from '@@/js/config.js';
import CkFollowMouse from '../CkFollowMouse.vue';
diff --git a/packages/frontend/src/components/global/MkStickyContainer.vue b/packages/frontend/src/components/global/MkStickyContainer.vue
index 05245716c2..73ce393113 100644
--- a/packages/frontend/src/components/global/MkStickyContainer.vue
+++ b/packages/frontend/src/components/global/MkStickyContainer.vue
@@ -5,17 +5,17 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div ref="rootEl">
- <div ref="headerEl" :class="$style.header">
+ <div ref="headerEl" :class="{ [$style.header]: sticky }">
<slot name="header"></slot>
</div>
<div
- :class="$style.body"
+ :class="{ [$style.body]: sticky }"
:data-sticky-container-header-height="headerHeight"
:data-sticky-container-footer-height="footerHeight"
>
<slot></slot>
</div>
- <div ref="footerEl" :class="$style.footer">
+ <div ref="footerEl" :class="{ [$style.footer]: sticky }">
<slot name="footer"></slot>
</div>
</div>
@@ -25,6 +25,12 @@ SPDX-License-Identifier: AGPL-3.0-only
import { onMounted, onUnmounted, provide, inject, ref, watch, useTemplateRef } from 'vue';
import { DI } from '@/di.js';
+withDefaults(defineProps<{
+ sticky?: boolean,
+}>(), {
+ sticky: true,
+});
+
const rootEl = useTemplateRef('rootEl');
const headerEl = useTemplateRef('headerEl');
const footerEl = useTemplateRef('footerEl');
diff --git a/packages/frontend/src/components/global/PageWithHeader.vue b/packages/frontend/src/components/global/PageWithHeader.vue
index d2e59bf4ad..485ea687de 100644
--- a/packages/frontend/src/components/global/PageWithHeader.vue
+++ b/packages/frontend/src/components/global/PageWithHeader.vue
@@ -6,8 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div ref="rootEl" :class="[$style.root, reversed ? '_pageScrollableReversed' : '_pageScrollable']">
<MkStickyContainer>
- <template #header><MkPageHeader v-model:tab="tab" v-bind="pageHeaderProps"/></template>
- <div :class="$style.body">
+ <template #header><MkPageHeader v-model:tab="tab" v-bind="pageHeaderProps" :class="{ _spacer: spacer }"/></template>
+ <div :class="[ $style.body, { _spacer: spacer } ]">
<MkSwiper v-if="swipable && (props.tabs?.length ?? 1) > 1" v-model:tab="tab" :class="$style.swiper" :tabs="props.tabs" :page="props.page">
<slot></slot>
</MkSwiper>
@@ -30,13 +30,16 @@ const props = withDefaults(defineProps<PageHeaderProps & {
reversed?: boolean;
swipable?: boolean;
page?: string;
+ spacer?: boolean;
}>(), {
reversed: false,
swipable: true,
+ page: undefined,
+ spacer: false,
});
const pageHeaderProps = computed(() => {
- const { reversed, ...rest } = props;
+ const { reversed, spacer, ...rest } = props;
return rest;
});
diff --git a/packages/frontend/src/components/global/StackingRouterView.vue b/packages/frontend/src/components/global/StackingRouterView.vue
index c95c74aef3..38a5c1ba23 100644
--- a/packages/frontend/src/components/global/StackingRouterView.vue
+++ b/packages/frontend/src/components/global/StackingRouterView.vue
@@ -4,12 +4,12 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
-<TransitionGroup
- :enterActiveClass="prefer.s.animation ? $style.transition_x_enterActive : ''"
- :leaveActiveClass="prefer.s.animation ? $style.transition_x_leaveActive : ''"
- :enterFromClass="prefer.s.animation ? $style.transition_x_enterFrom : ''"
- :leaveToClass="prefer.s.animation ? $style.transition_x_leaveTo : ''"
- :moveClass="prefer.s.animation ? $style.transition_x_move : ''"
+<SkTransitionGroup
+ :enterActiveClass="$style.transition_x_enterActive"
+ :leaveActiveClass="$style.transition_x_leaveActive"
+ :enterFromClass="$style.transition_x_enterFrom"
+ :leaveToClass="$style.transition_x_leaveTo"
+ :moveClass="$style.transition_x_move"
:duration="200"
tag="div" :class="$style.tabs"
>
@@ -37,7 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
</div>
-</TransitionGroup>
+</SkTransitionGroup>
</template>
<script lang="ts" setup>
@@ -47,6 +47,7 @@ import { prefer } from '@/preferences.js';
import MkLoadingPage from '@/pages/_loading_.vue';
import { DI } from '@/di.js';
import { deepEqual } from '@/utility/deep-equal.js';
+import SkTransitionGroup from '@/components/SkTransitionGroup.vue';
const props = defineProps<{
router?: Router;
diff --git a/packages/frontend/src/components/page/page.note.vue b/packages/frontend/src/components/page/page.note.vue
index df26874c17..543e9afdaf 100644
--- a/packages/frontend/src/components/page/page.note.vue
+++ b/packages/frontend/src/components/page/page.note.vue
@@ -11,8 +11,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
-import { onMounted, ref } from 'vue';
+import { onMounted, onUnmounted, ref } from 'vue';
import * as Misskey from 'misskey-js';
+import { retryOnThrottled } from '@@/js/retry-on-throttled.js';
import MkNote from '@/components/MkNote.vue';
import MkNoteDetailed from '@/components/MkNoteDetailed.vue';
import { misskeyApi } from '@/utility/misskey-api.js';
@@ -20,16 +21,25 @@ import { misskeyApi } from '@/utility/misskey-api.js';
const props = defineProps<{
block: Misskey.entities.PageBlock,
page: Misskey.entities.Page,
+ index: number;
}>();
const note = ref<Misskey.entities.Note | null>(null);
+// eslint-disable-next-line id-denylist
+let timeoutId: ReturnType<typeof window.setTimeout> | null = null;
+
onMounted(() => {
if (props.block.note == null) return;
- misskeyApi('notes/show', { noteId: props.block.note })
- .then(result => {
- note.value = result;
- });
+ timeoutId = window.setTimeout(async () => {
+ note.value = await retryOnThrottled(() => misskeyApi('notes/show', { noteId: props.block.note }));
+ }, 500 * props.index); // rate limit is 2 reqs per sec
+});
+
+onUnmounted(() => {
+ if (timeoutId !== null) {
+ window.clearTimeout(timeoutId);
+ }
});
</script>
diff --git a/packages/frontend/src/components/page/page.text.vue b/packages/frontend/src/components/page/page.text.vue
index ef3524fe7a..3891380dd0 100644
--- a/packages/frontend/src/components/page/page.text.vue
+++ b/packages/frontend/src/components/page/page.text.vue
@@ -7,29 +7,20 @@ SPDX-License-Identifier: AGPL-3.0-only
<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" :showAsQuote="!page.user.rejectQuotes"/>
+ <SkUrlPreviewGroup :sourceText="block.text" :showAsQuote="!page.user.rejectQuotes" @click.stop/>
</div>
</div>
</template>
<script lang="ts" setup>
-import { defineAsyncComponent, computed } from 'vue';
-import * as mfm from '@transfem-org/sfm-js';
import * as Misskey from 'misskey-js';
-import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
import { isEnabledUrlPreview } from '@/instance.js';
+import SkUrlPreviewGroup from '@/components/SkUrlPreviewGroup.vue';
-const MkUrlPreview = defineAsyncComponent(() => import('@/components/MkUrlPreview.vue'));
-
-const props = defineProps<{
+defineProps<{
block: Misskey.entities.PageBlock,
page: Misskey.entities.Page,
}>();
-
-const urls = computed(() => {
- if (!props.block.text) return [];
- return extractUrlFromMfm(mfm.parse(props.block.text));
-});
</script>
<style lang="scss" module>
diff --git a/packages/frontend/src/components/page/page.vue b/packages/frontend/src/components/page/page.vue
index a31c5eff28..9f9feeed49 100644
--- a/packages/frontend/src/components/page/page.vue
+++ b/packages/frontend/src/components/page/page.vue
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<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"/>
+ <XBlock v-for="(child, index) in page.content" :key="child.id" :index="index" :page="page" :block="child" :h="2"/>
</div>
</template>