diff options
| author | dakkar <dakkar@thenautilus.net> | 2024-06-18 14:25:04 +0000 |
|---|---|---|
| committer | dakkar <dakkar@thenautilus.net> | 2024-06-18 14:25:04 +0000 |
| commit | b7805adc85653d8d789728dfaaa6a7e80d1440b8 (patch) | |
| tree | 0109695430a1e604e68ddc2b7137f1fa7e354e4c /packages/frontend/src | |
| parent | merge: feat: add an option to collapse replies (!545) (diff) | |
| parent | merge: merge up to 2024.5.0 (!537) (diff) | |
| download | sharkey-b7805adc85653d8d789728dfaaa6a7e80d1440b8.tar.gz sharkey-b7805adc85653d8d789728dfaaa6a7e80d1440b8.tar.bz2 sharkey-b7805adc85653d8d789728dfaaa6a7e80d1440b8.zip | |
merge: prepare for 2024.5, 2nd try (!554)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/554
Closes #494
Approved-by: Tess K <me@thvxl.se>
Approved-by: Marie <marie@kaifa.ch>
Diffstat (limited to 'packages/frontend/src')
165 files changed, 2747 insertions, 887 deletions
diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts index 9694a5b627..94040c6413 100644 --- a/packages/frontend/src/boot/common.ts +++ b/packages/frontend/src/boot/common.ts @@ -145,8 +145,11 @@ export async function common(createVue: () => App<Element>) { // NOTE: この処理は必ずクライアント更新チェック処理より後に来ること(テーマ再構築のため) watch(defaultStore.reactiveState.darkMode, (darkMode) => { applyTheme(darkMode ? ColdDeviceStorage.get('darkTheme') : ColdDeviceStorage.get('lightTheme')); + document.documentElement.dataset.colorMode = darkMode ? 'dark' : 'light'; }, { immediate: miLocalStorage.getItem('theme') == null }); + document.documentElement.dataset.colorMode = defaultStore.state.darkMode ? 'dark' : 'light'; + const darkTheme = computed(ColdDeviceStorage.makeGetterSetter('darkTheme')); const lightTheme = computed(ColdDeviceStorage.makeGetterSetter('lightTheme')); diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts index a1c74421de..013fa7c37c 100644 --- a/packages/frontend/src/boot/main-boot.ts +++ b/packages/frontend/src/boot/main-boot.ts @@ -189,14 +189,26 @@ export async function mainBoot() { if ($i.followersCount >= 500) claimAchievement('followers500'); if ($i.followersCount >= 1000) claimAchievement('followers1000'); - if (Date.now() - new Date($i.createdAt).getTime() > 1000 * 60 * 60 * 24 * 365) { - claimAchievement('passedSinceAccountCreated1'); - } - if (Date.now() - new Date($i.createdAt).getTime() > 1000 * 60 * 60 * 24 * 365 * 2) { - claimAchievement('passedSinceAccountCreated2'); - } - if (Date.now() - new Date($i.createdAt).getTime() > 1000 * 60 * 60 * 24 * 365 * 3) { + const createdAt = new Date($i.createdAt); + const createdAtThreeYearsLater = new Date($i.createdAt); + createdAtThreeYearsLater.setFullYear(createdAtThreeYearsLater.getFullYear() + 3); + if (now >= createdAtThreeYearsLater) { claimAchievement('passedSinceAccountCreated3'); + claimAchievement('passedSinceAccountCreated2'); + claimAchievement('passedSinceAccountCreated1'); + } else { + const createdAtTwoYearsLater = new Date($i.createdAt); + createdAtTwoYearsLater.setFullYear(createdAtTwoYearsLater.getFullYear() + 2); + if (now >= createdAtTwoYearsLater) { + claimAchievement('passedSinceAccountCreated2'); + claimAchievement('passedSinceAccountCreated1'); + } else { + const createdAtOneYearLater = new Date($i.createdAt); + createdAtOneYearLater.setFullYear(createdAtOneYearLater.getFullYear() + 1); + if (now >= createdAtOneYearLater) { + claimAchievement('passedSinceAccountCreated1'); + } + } } if (claimedAchievements.length >= 30) { @@ -231,7 +243,7 @@ export async function mainBoot() { const latestDonationInfoShownAt = miLocalStorage.getItem('latestDonationInfoShownAt'); const neverShowDonationInfo = miLocalStorage.getItem('neverShowDonationInfo'); - if (neverShowDonationInfo !== 'true' && (new Date($i.createdAt).getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 3))) && !location.pathname.startsWith('/miauth')) { + if (neverShowDonationInfo !== 'true' && (createdAt.getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 3))) && !location.pathname.startsWith('/miauth')) { if (latestDonationInfoShownAt == null || (new Date(latestDonationInfoShownAt).getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 30)))) { popup(defineAsyncComponent(() => import('@/components/MkDonation.vue')), {}, {}, 'closed'); } diff --git a/packages/frontend/src/cache.ts b/packages/frontend/src/cache.ts index b286528de6..bfe8fbe0e4 100644 --- a/packages/frontend/src/cache.ts +++ b/packages/frontend/src/cache.ts @@ -11,3 +11,4 @@ export const clipsCache = new Cache<Misskey.entities.Clip[]>(1000 * 60 * 30, () export const rolesCache = new Cache(1000 * 60 * 30, () => misskeyApi('admin/roles/list')); export const userListsCache = new Cache<Misskey.entities.UserList[]>(1000 * 60 * 30, () => misskeyApi('users/lists/list')); export const antennasCache = new Cache<Misskey.entities.Antenna[]>(1000 * 60 * 30, () => misskeyApi('antennas/list')); +export const favoritedChannelsCache = new Cache<Misskey.entities.Channel[]>(1000 * 60 * 30, () => misskeyApi('channels/my-favorites', { limit: 100 })); diff --git a/packages/frontend/src/components/CkFollowMouse.vue b/packages/frontend/src/components/CkFollowMouse.vue index b55a577b3f..ce7e3c79a8 100644 --- a/packages/frontend/src/components/CkFollowMouse.vue +++ b/packages/frontend/src/components/CkFollowMouse.vue @@ -1,3 +1,8 @@ +<!-- +SPDX-FileCopyrightText: leah and other Cutiekey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + <template> <span ref="container" :class="$style.root"> <span ref="el" :class="$style.inner" style="position: absolute"> diff --git a/packages/frontend/src/components/MkAbuseReport.vue b/packages/frontend/src/components/MkAbuseReport.vue index 7174affabe..7c8c7dbd30 100644 --- a/packages/frontend/src/components/MkAbuseReport.vue +++ b/packages/frontend/src/components/MkAbuseReport.vue @@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div class="detail"> <div> - <Mfm :text="report.comment" :isBlock="true" /> + <Mfm :text="report.comment" :isBlock="true" :linkNavigationBehavior="'window'"/> </div> <hr/> <div>{{ i18n.ts.reporter }}: <MkA :to="`/admin/user/${report.reporter.id}`" class="_link" :behavior="'window'">@{{ report.reporter.username }}</MkA></div> diff --git a/packages/frontend/src/components/MkAccountMoved.stories.impl.ts b/packages/frontend/src/components/MkAccountMoved.stories.impl.ts index f1cfdc157a..cad26de6e2 100644 --- a/packages/frontend/src/components/MkAccountMoved.stories.impl.ts +++ b/packages/frontend/src/components/MkAccountMoved.stories.impl.ts @@ -4,7 +4,10 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { action } from '@storybook/addon-actions'; import { StoryObj } from '@storybook/vue3'; +import { HttpResponse, http } from 'msw'; +import { commonHandlers } from '../../.storybook/mocks.js'; import { userDetailed } from '../../.storybook/fakes.js'; import MkAccountMoved from './MkAccountMoved.vue'; export const Default = { @@ -29,10 +32,18 @@ export const Default = { }; }, args: { - username: userDetailed().username, - host: userDetailed().host, + movedTo: userDetailed().id, }, parameters: { layout: 'centered', + msw: { + handlers: [ + ...commonHandlers, + http.post('/api/users/show', async ({ request }) => { + action('POST /api/users/show')(await request.json()); + return HttpResponse.json(userDetailed()); + }), + ], + }, }, } satisfies StoryObj<typeof MkAccountMoved>; diff --git a/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts b/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts index ffa4e56f5f..bf3ddb935b 100644 --- a/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts +++ b/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts @@ -4,7 +4,10 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { action } from '@storybook/addon-actions'; import { StoryObj } from '@storybook/vue3'; +import { HttpResponse, http } from 'msw'; +import { commonHandlers } from '../../.storybook/mocks.js'; import MkAnnouncementDialog from './MkAnnouncementDialog.vue'; export const Default = { render(args) { @@ -23,8 +26,13 @@ export const Default = { ...this.args, }; }, + events() { + return { + closed: action('closed'), + }; + }, }, - template: '<MkAnnouncementDialog v-bind="props" />', + template: '<MkAnnouncementDialog v-bind="props" v-on="events" />', }; }, args: { @@ -38,10 +46,20 @@ export const Default = { imageUrl: null, display: 'dialog', needConfirmationToRead: false, + silence: false, forYou: true, }, }, parameters: { layout: 'centered', + msw: { + handlers: [ + ...commonHandlers, + http.post('/api/i/read-announcement', async ({ request }) => { + action('POST /api/i/read-announcement')(await request.json()); + return HttpResponse.json(); + }), + ], + }, }, } satisfies StoryObj<typeof MkAnnouncementDialog>; diff --git a/packages/frontend/src/components/MkAsUi.vue b/packages/frontend/src/components/MkAsUi.vue index 11f454daa2..7e150f7dd5 100644 --- a/packages/frontend/src/components/MkAsUi.vue +++ b/packages/frontend/src/components/MkAsUi.vue @@ -44,6 +44,8 @@ SPDX-License-Identifier: AGPL-3.0-only :instant="true" :initialText="c.form?.text" :initialCw="c.form?.cw" + :initialVisibility="c.form?.visibility" + :initialLocalOnly="c.form?.localOnly" /> </div> <MkFolder v-else-if="c.type === 'folder'" :defaultOpen="c.opened"> @@ -111,6 +113,8 @@ function openPostForm() { os.post({ initialText: form.text, initialCw: form.cw, + initialVisibility: form.visibility, + initialLocalOnly: form.localOnly, instant: true, }); } diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue index c0f41b64d0..c8d2797e16 100644 --- a/packages/frontend/src/components/MkButton.vue +++ b/packages/frontend/src/components/MkButton.vue @@ -23,6 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only v-else class="_button" :class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike }]" :to="to ?? '#'" + :behavior="linkBehavior" @mousedown="onMousedown" > <div ref="ripples" :class="$style.ripples" :data-children-class="$style.ripple"></div> @@ -43,6 +44,7 @@ const props = defineProps<{ inline?: boolean; link?: boolean; to?: string; + linkBehavior?: null | 'window' | 'browser'; autofocus?: boolean; wait?: boolean; danger?: boolean; diff --git a/packages/frontend/src/components/MkClipPreview.vue b/packages/frontend/src/components/MkClipPreview.vue index c51ad4356d..6299a28e9f 100644 --- a/packages/frontend/src/components/MkClipPreview.vue +++ b/packages/frontend/src/components/MkClipPreview.vue @@ -4,37 +4,59 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="$style.root" class="_panel"> - <b>{{ clip.name }}</b> - <div v-if="clip.description" :class="$style.description">{{ clip.description }}</div> - <div v-if="clip.lastClippedAt">{{ i18n.ts.updatedAt }}: <MkTime :time="clip.lastClippedAt" mode="detail"/></div> - <div :class="$style.user"> - <MkAvatar :user="clip.user" :class="$style.userAvatar" indicator link preview/> <MkUserName :user="clip.user" :nowrap="false"/> +<MkA :to="`/clips/${clip.id}`" :class="$style.link"> + <div :class="$style.root" class="_panel _gaps_s"> + <b>{{ clip.name }}</b> + <div :class="$style.description"> + <div v-if="clip.description"><Mfm :text="clip.description" :plain="true" :nowrap="true"/></div> + <div v-if="clip.lastClippedAt">{{ i18n.ts.updatedAt }}: <MkTime :time="clip.lastClippedAt" mode="detail"/></div> + <div v-if="clip.notesCount != null">{{ i18n.ts.notesCount }}: {{ number(clip.notesCount) }} / {{ $i?.policies.noteEachClipsLimit }} ({{ i18n.tsx.remainingN({ n: remaining }) }})</div> + </div> + <div :class="$style.divider"></div> + <div> + <MkAvatar :user="clip.user" :class="$style.userAvatar" indicator link preview/> <MkUserName :user="clip.user" :nowrap="false"/> + </div> </div> -</div> +</MkA> </template> <script lang="ts" setup> +import * as Misskey from 'misskey-js'; +import { computed } from 'vue'; import { i18n } from '@/i18n.js'; +import { $i } from '@/account.js'; +import number from '@/filters/number.js'; -defineProps<{ - clip: any; +const props = defineProps<{ + clip: Misskey.entities.Clip; }>(); + +const remaining = computed(() => { + return ($i?.policies && props.clip.notesCount != null) ? ($i.policies.noteEachClipsLimit - props.clip.notesCount) : i18n.ts.unknown; +}); </script> <style lang="scss" module> -.root { +.link { display: block; + + &:hover { + text-decoration: none; + color: var(--accent); + } +} + +.root { padding: 16px; } -.description { - padding: 8px 0; +.divider { + height: 1px; + background: var(--divider); } -.user { - padding-top: 16px; - border-top: solid 0.5px var(--divider); +.description { + font-size: 90%; } .userAvatar { diff --git a/packages/frontend/src/components/MkCode.core.vue b/packages/frontend/src/components/MkCode.core.vue index a23b4dc3b2..9e54420034 100644 --- a/packages/frontend/src/components/MkCode.core.vue +++ b/packages/frontend/src/components/MkCode.core.vue @@ -9,9 +9,9 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, computed, watch } from 'vue'; -import { bundledLanguagesInfo } from 'shiki'; -import type { BuiltinLanguage } from 'shiki'; +import { computed, ref, watch } from 'vue'; +import { bundledLanguagesInfo } from 'shiki/langs'; +import type { BundledLanguage } from 'shiki/langs'; import { getHighlighter, getTheme } from '@/scripts/code-highlighter.js'; import { defaultStore } from '@/store.js'; @@ -23,7 +23,7 @@ const props = defineProps<{ const highlighter = await getHighlighter(); const darkMode = defaultStore.reactiveState.darkMode; -const codeLang = ref<BuiltinLanguage | 'aiscript'>('js'); +const codeLang = ref<BundledLanguage | 'aiscript'>('js'); const [lightThemeName, darkThemeName] = await Promise.all([ getTheme('light', true), @@ -42,7 +42,7 @@ const html = computed(() => highlighter.codeToHtml(props.code, { })); async function fetchLanguage(to: string): Promise<void> { - const language = to as BuiltinLanguage; + const language = to as BundledLanguage; // Check for the loaded languages, and load the language if it's not loaded yet. if (!highlighter.getLoadedLanguages().includes(language)) { diff --git a/packages/frontend/src/components/MkCode.vue b/packages/frontend/src/components/MkCode.vue index acd2ea6f97..e5589813e1 100644 --- a/packages/frontend/src/components/MkCode.vue +++ b/packages/frontend/src/components/MkCode.vue @@ -80,11 +80,9 @@ function copy() { .codePlaceholderRoot { display: block; width: 100%; - background: none; border: none; outline: none; font: inherit; - color: inherit; cursor: pointer; box-sizing: border-box; diff --git a/packages/frontend/src/components/MkContextMenu.vue b/packages/frontend/src/components/MkContextMenu.vue index 5ca3c77fb2..a807742bb9 100644 --- a/packages/frontend/src/components/MkContextMenu.vue +++ b/packages/frontend/src/components/MkContextMenu.vue @@ -47,12 +47,12 @@ onMounted(() => { const width = rootEl.value!.offsetWidth; const height = rootEl.value!.offsetHeight; - if (left + width - window.pageXOffset >= (window.innerWidth - SCROLLBAR_THICKNESS)) { - left = (window.innerWidth - SCROLLBAR_THICKNESS) - width + window.pageXOffset; + if (left + width - window.scrollX >= (window.innerWidth - SCROLLBAR_THICKNESS)) { + left = (window.innerWidth - SCROLLBAR_THICKNESS) - width + window.scrollX; } - if (top + height - window.pageYOffset >= (window.innerHeight - SCROLLBAR_THICKNESS)) { - top = (window.innerHeight - SCROLLBAR_THICKNESS) - height + window.pageYOffset; + if (top + height - window.scrollY >= (window.innerHeight - SCROLLBAR_THICKNESS)) { + top = (window.innerHeight - SCROLLBAR_THICKNESS) - height + window.scrollY; } if (top < 0) { diff --git a/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue b/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue index 84b5375a41..c7f1288729 100644 --- a/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue +++ b/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue @@ -4,77 +4,81 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> - <MkModalWindow ref="dialogEl" @close="cancel()" @closed="$emit('closed')"> - <template #header>:{{ emoji.name }}:</template> - <template #default> - <MkSpacer> - <div style="display: flex; flex-direction: column; gap: 1em;"> - <div :class="$style.emojiImgWrapper"> - <MkCustomEmoji :name="emoji.name" :normal="true" :useOriginalSize="true" style="height: 100%;"></MkCustomEmoji> - </div> - <MkKeyValue :copy="`:${emoji.name}:`"> - <template #key>{{ i18n.ts.name }}</template> - <template #value>{{ emoji.name }}</template> - </MkKeyValue> - <MkKeyValue> - <template #key>{{ i18n.ts.tags }}</template> - <template #value> - <div v-if="emoji.aliases.length === 0">{{ i18n.ts.none }}</div> - <div v-else :class="$style.aliases"> - <span v-for="alias in emoji.aliases" :key="alias" :class="$style.alias"> - {{ alias }} - </span> - </div> - </template> - </MkKeyValue> - <MkKeyValue> - <template #key>{{ i18n.ts.category }}</template> - <template #value>{{ emoji.category ?? i18n.ts.none }}</template> - </MkKeyValue> - <MkKeyValue> - <template #key>{{ i18n.ts.sensitive }}</template> - <template #value>{{ emoji.isSensitive ? i18n.ts.yes : i18n.ts.no }}</template> - </MkKeyValue> - <MkKeyValue> - <template #key>{{ i18n.ts.localOnly }}</template> - <template #value>{{ emoji.localOnly ? i18n.ts.yes : i18n.ts.no }}</template> - </MkKeyValue> - <MkKeyValue> - <template #key>{{ i18n.ts.license }}</template> - <template #value><Mfm :text="emoji.license ?? i18n.ts.none" /></template> - </MkKeyValue> - <MkKeyValue :copy="emoji.url"> - <template #key>{{ i18n.ts.emojiUrl }}</template> - <template #value> - <MkLink :url="emoji.url" target="_blank">{{ emoji.url }}</MkLink> - </template> - </MkKeyValue> - </div> - </MkSpacer> - </template> - </MkModalWindow> +<MkModalWindow ref="dialogEl" @close="cancel()" @closed="$emit('closed')"> + <template #header>:{{ emoji.name }}:</template> + <template #default> + <MkSpacer> + <div style="display: flex; flex-direction: column; gap: 1em;"> + <div :class="$style.emojiImgWrapper"> + <MkCustomEmoji :name="emoji.name" :normal="true" :useOriginalSize="true" style="height: 100%;"></MkCustomEmoji> + </div> + <MkKeyValue :copy="`:${emoji.name}:`"> + <template #key>{{ i18n.ts.name }}</template> + <template #value>{{ emoji.name }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>{{ i18n.ts.tags }}</template> + <template #value> + <div v-if="emoji.aliases.length === 0">{{ i18n.ts.none }}</div> + <div v-else :class="$style.aliases"> + <span v-for="alias in emoji.aliases" :key="alias" :class="$style.alias"> + {{ alias }} + </span> + </div> + </template> + </MkKeyValue> + <MkKeyValue> + <template #key>{{ i18n.ts.category }}</template> + <template #value>{{ emoji.category ?? i18n.ts.none }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>{{ i18n.ts.sensitive }}</template> + <template #value>{{ emoji.isSensitive ? i18n.ts.yes : i18n.ts.no }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>{{ i18n.ts.localOnly }}</template> + <template #value>{{ emoji.localOnly ? i18n.ts.yes : i18n.ts.no }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>{{ i18n.ts.license }}</template> + <template #value><Mfm :text="emoji.license ?? i18n.ts.none"/></template> + </MkKeyValue> + <MkKeyValue :copy="emoji.url"> + <template #key>{{ i18n.ts.emojiUrl }}</template> + <template #value> + <MkLink :url="emoji.url" target="_blank">{{ emoji.url }}</MkLink> + </template> + </MkKeyValue> + </div> + </MkSpacer> + </template> +</MkModalWindow> </template> <script lang="ts" setup> import * as Misskey from 'misskey-js'; import { defineProps, shallowRef } from 'vue'; +import MkLink from '@/components/MkLink.vue'; import { i18n } from '@/i18n.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import MkKeyValue from '@/components/MkKeyValue.vue'; -import MkLink from './MkLink.vue'; + const props = defineProps<{ emoji: Misskey.entities.EmojiDetailed, }>(); + const emit = defineEmits<{ (ev: 'ok', cropped: Misskey.entities.DriveFile): void; (ev: 'cancel'): void; (ev: 'closed'): void; }>(); + const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>(); -const cancel = () => { + +function cancel() { emit('cancel'); dialogEl.value!.close(); -}; +} </script> <style lang="scss" module> diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue index f51758ada7..b534ae4c56 100644 --- a/packages/frontend/src/components/MkDialog.vue +++ b/packages/frontend/src/components/MkDialog.vue @@ -161,7 +161,7 @@ function onKeydown(evt: KeyboardEvent) { } function onInputKeydown(evt: KeyboardEvent) { - if (evt.key === 'Enter') { + if (evt.key === 'Enter' && okButtonDisabledReason.value === null) { evt.preventDefault(); evt.stopPropagation(); ok(); diff --git a/packages/frontend/src/components/MkFeaturedPhotos.vue b/packages/frontend/src/components/MkFeaturedPhotos.vue index 8d875790bc..c42c692db0 100644 --- a/packages/frontend/src/components/MkFeaturedPhotos.vue +++ b/packages/frontend/src/components/MkFeaturedPhotos.vue @@ -4,19 +4,11 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div v-if="meta" :class="$style.root" :style="{ backgroundImage: `url(${ meta.backgroundImageUrl })` }"></div> +<div v-if="instance" :class="$style.root" :style="{ backgroundImage: `url(${ instance.backgroundImageUrl })` }"></div> </template> <script lang="ts" setup> -import { ref } from 'vue'; -import * as Misskey from 'misskey-js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; - -const meta = ref<Misskey.entities.MetaResponse>(); - -misskeyApi('meta', { detail: true }).then(gotMeta => { - meta.value = gotMeta; -}); +import { instance } from '@/instance.js'; </script> <style lang="scss" module> diff --git a/packages/frontend/src/components/MkFollowButton.vue b/packages/frontend/src/components/MkFollowButton.vue index d0e8750e6a..c0a625c69f 100644 --- a/packages/frontend/src/components/MkFollowButton.vue +++ b/packages/frontend/src/components/MkFollowButton.vue @@ -93,6 +93,18 @@ async function onClick() { userId: props.user.id, }); } else { + if (defaultStore.state.alwaysConfirmFollow) { + const { canceled } = await os.confirm({ + type: 'question', + text: i18n.tsx.followConfirm({ name: props.user.name || props.user.username }), + }); + + if (canceled) { + wait.value = false; + return; + } + } + if (hasPendingFollowRequestFromYou.value) { await misskeyApi('following/requests/cancel', { userId: props.user.id, diff --git a/packages/frontend/src/components/MkFormDialog.file.vue b/packages/frontend/src/components/MkFormDialog.file.vue new file mode 100644 index 0000000000..9360594236 --- /dev/null +++ b/packages/frontend/src/components/MkFormDialog.file.vue @@ -0,0 +1,71 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div> + <MkButton inline rounded primary @click="selectButton($event)">{{ i18n.ts.selectFile }}</MkButton> + <div :class="['_nowrap', !fileName && $style.fileNotSelected]">{{ friendlyFileName }}</div> +</div> +</template> + +<script setup lang="ts"> +import * as Misskey from 'misskey-js'; +import { computed, ref } from 'vue'; +import { i18n } from '@/i18n.js'; +import MkButton from '@/components/MkButton.vue'; +import { selectFile } from '@/scripts/select-file.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; + +const props = defineProps<{ + fileId?: string | null; + validate?: (file: Misskey.entities.DriveFile) => Promise<boolean>; +}>(); + +const emit = defineEmits<{ + (ev: 'update', result: Misskey.entities.DriveFile): void; +}>(); + +const fileUrl = ref(''); +const fileName = ref<string>(''); + +const friendlyFileName = computed<string>(() => { + if (fileName.value) { + return fileName.value; + } + if (fileUrl.value) { + return fileUrl.value; + } + + return i18n.ts.fileNotSelected; +}); + +if (props.fileId) { + misskeyApi('drive/files/show', { + fileId: props.fileId, + }).then((apiRes) => { + fileName.value = apiRes.name; + fileUrl.value = apiRes.url; + }); +} + +function selectButton(ev: MouseEvent) { + selectFile(ev.currentTarget ?? ev.target).then(async (file) => { + if (!file) return; + if (props.validate && !await props.validate(file)) return; + + emit('update', file); + fileName.value = file.name; + fileUrl.value = file.url; + }); +} + +</script> + +<style module> +.fileNotSelected { + font-weight: 700; + color: var(--infoWarnFg); +} +</style> diff --git a/packages/frontend/src/components/MkFormDialog.vue b/packages/frontend/src/components/MkFormDialog.vue index deedc5badb..124f114111 100644 --- a/packages/frontend/src/components/MkFormDialog.vue +++ b/packages/frontend/src/components/MkFormDialog.vue @@ -21,8 +21,9 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSpacer :marginMin="20" :marginMax="32"> <div v-if="Object.keys(form).filter(item => !form[item].hidden).length > 0" class="_gaps_m"> - <template v-for="(v, k) in Object.fromEntries(Object.entries(form).filter(([_, v]) => !('hidden' in v) || 'hidden' in v && !v.hidden))"> - <MkInput v-if="v.type === 'number'" v-model="values[k]" type="number" :step="v.step || 1"> + <template v-for="(v, k) in Object.fromEntries(Object.entries(form))"> + <template v-if="typeof v.hidden == 'function' ? v.hidden(values) : v.hidden"></template> + <MkInput v-else-if="v.type === 'number'" v-model="values[k]" type="number" :step="v.step || 1"> <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> <template v-if="v.description" #caption>{{ v.description }}</template> </MkInput> @@ -53,6 +54,12 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton v-else-if="v.type === 'button'" @click="v.action($event, values)"> <span v-text="v.content || k"></span> </MkButton> + <XFile + v-else-if="v.type === 'drive-file'" + :fileId="v.defaultFileId" + :validate="async f => !v.validate || await v.validate(f)" + @update="f => values[k] = f" + /> </template> </div> <div v-else class="_fullinfo"> @@ -72,6 +79,7 @@ import MkSelect from './MkSelect.vue'; import MkRange from './MkRange.vue'; import MkButton from './MkButton.vue'; import MkRadios from './MkRadios.vue'; +import XFile from './MkFormDialog.file.vue'; import type { Form } from '@/scripts/form.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/components/MkInput.vue b/packages/frontend/src/components/MkInput.vue index b026903b66..201b409a05 100644 --- a/packages/frontend/src/components/MkInput.vue +++ b/packages/frontend/src/components/MkInput.vue @@ -22,6 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only :autocomplete="autocomplete" :autocapitalize="autocapitalize" :spellcheck="spellcheck" + :inputmode="inputmode" :step="step" :list="id" :min="min" @@ -63,6 +64,7 @@ const props = defineProps<{ mfmAutocomplete?: boolean | SuggestionType[], autocapitalize?: string; spellcheck?: boolean; + inputmode?: 'none' | 'text' | 'search' | 'email' | 'url' | 'numeric' | 'tel' | 'decimal'; step?: any; datalist?: string[]; min?: number; diff --git a/packages/frontend/src/components/MkLink.vue b/packages/frontend/src/components/MkLink.vue index 95de0d0247..49cbacd1e8 100644 --- a/packages/frontend/src/components/MkLink.vue +++ b/packages/frontend/src/components/MkLink.vue @@ -6,6 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <component :is="self ? 'MkA' : 'a'" ref="el" style="word-break: break-all;" class="_link" :[attr]="self ? url.substring(local.length) : url" :rel="rel ?? 'nofollow noopener'" :target="target" + :behavior="props.navigationBehavior" :title="url" @click.stop > @@ -19,10 +20,13 @@ import { defineAsyncComponent, ref } from 'vue'; import { url as local } from '@/config.js'; import { useTooltip } from '@/scripts/use-tooltip.js'; import * as os from '@/os.js'; +import { isEnabledUrlPreview } from '@/instance.js'; +import { MkABehavior } from '@/components/global/MkA.vue'; const props = withDefaults(defineProps<{ url: string; rel?: null | string; + navigationBehavior?: MkABehavior; }>(), { }); @@ -30,15 +34,17 @@ const self = props.url.startsWith(local); const attr = self ? 'to' : 'href'; const target = self ? null : '_blank'; -const el = ref<HTMLElement>(); +const el = ref<HTMLElement | { $el: HTMLElement }>(); -useTooltip(el, (showing) => { - os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), { - showing, - url: props.url, - source: el.value, - }, {}, 'closed'); -}); +if (isEnabledUrlPreview.value) { + useTooltip(el, (showing) => { + os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), { + showing, + url: props.url, + source: el.value instanceof HTMLElement ? el.value : el.value?.$el, + }, {}, 'closed'); + }); +} </script> <style lang="scss" module> diff --git a/packages/frontend/src/components/MkMediaAudio.vue b/packages/frontend/src/components/MkMediaAudio.vue index 6351f5cfbe..41425facc3 100644 --- a/packages/frontend/src/components/MkMediaAudio.vue +++ b/packages/frontend/src/components/MkMediaAudio.vue @@ -5,11 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div + ref="playerEl" + v-hotkey="keymap" + tabindex="0" :class="[ $style.audioContainer, (audio.isSensitive && defaultStore.state.highlightSensitiveMedia) && $style.sensitive, ]" @contextmenu.stop + @keydown.stop > <button v-if="hide" :class="$style.hidden" @click="hide = false"> <div :class="$style.hiddenTextWrapper"> @@ -18,6 +22,19 @@ SPDX-License-Identifier: AGPL-3.0-only <span style="display: block;">{{ i18n.ts.clickToShow }}</span> </div> </button> + + <div v-else-if="defaultStore.reactiveState.useNativeUIForVideoAudioPlayer.value" :class="$style.nativeAudioContainer"> + <audio + ref="audioEl" + preload="metadata" + controls + :class="$style.nativeAudio" + @keydown.prevent + > + <source :src="audio.url"> + </audio> + </div> + <div v-else :class="$style.audioControls"> <audio ref="audioEl" @@ -69,12 +86,47 @@ import * as os from '@/os.js'; import bytes from '@/filters/bytes.js'; import { hms } from '@/filters/hms.js'; import MkMediaRange from '@/components/MkMediaRange.vue'; -import { iAmModerator } from '@/account.js'; +import { $i, iAmModerator } from '@/account.js'; const props = defineProps<{ audio: Misskey.entities.DriveFile; }>(); +const keymap = { + 'up': () => { + if (hasFocus() && audioEl.value) { + volume.value = Math.min(volume.value + 0.1, 1); + } + }, + 'down': () => { + if (hasFocus() && audioEl.value) { + volume.value = Math.max(volume.value - 0.1, 0); + } + }, + 'left': () => { + if (hasFocus() && audioEl.value) { + audioEl.value.currentTime = Math.max(audioEl.value.currentTime - 5, 0); + } + }, + 'right': () => { + if (hasFocus() && audioEl.value) { + audioEl.value.currentTime = Math.min(audioEl.value.currentTime + 5, audioEl.value.duration); + } + }, + 'space': () => { + if (hasFocus()) { + togglePlayPause(); + } + }, +}; + +// PlayerElもしくはその子要素にフォーカスがあるかどうか +function hasFocus() { + if (!playerEl.value) return false; + return playerEl.value === document.activeElement || playerEl.value.contains(document.activeElement); +} + +const playerEl = shallowRef<HTMLDivElement>(); const audioEl = shallowRef<HTMLAudioElement>(); // eslint-disable-next-line vue/no-setup-props-destructure @@ -89,6 +141,30 @@ function showMenu(ev: MouseEvent) { menu = [ // TODO: 再生キューに追加 { + type: 'switch', + text: i18n.ts._mediaControls.loop, + icon: 'ph ph-repeat', + ref: loop, + }, + { + type: 'radio', + text: i18n.ts._mediaControls.playbackRate, + icon: 'ph ph-gauge', + ref: speed, + options: { + '0.25x': 0.25, + '0.5x': 0.5, + '0.75x': 0.75, + '1.0x': 1, + '1.25x': 1.25, + '1.5x': 1.5, + '2.0x': 2, + }, + }, + { + type: 'divider', + }, + { text: i18n.ts.hide, icon: 'ph-eye-closed ph-bold ph-lg', action: () => { @@ -99,8 +175,6 @@ function showMenu(ev: MouseEvent) { if (iAmModerator) { menu.push({ - type: 'divider', - }, { text: props.audio.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive, icon: props.audio.isSensitive ? 'ph-eye ph-bold ph-lg' : 'ph-eye-slash ph-bold ph-lg', danger: true, @@ -108,6 +182,17 @@ function showMenu(ev: MouseEvent) { }); } + if ($i?.id === props.audio.userId) { + menu.push({ + type: 'divider', + }, { + type: 'link' as const, + text: i18n.ts._fileViewer.title, + icon: 'ph ph-info', + to: `/my/drive/file/${props.audio.id}`, + }); + } + menuShowing.value = true; os.popupMenu(menu, ev.currentTarget ?? ev.target, { align: 'right', @@ -141,6 +226,8 @@ const rangePercent = computed({ }, }); const volume = ref(.25); +const speed = ref(1); +const loop = ref(false); // TODO: ドライブファイルのフラグに置き換える const bufferedEnd = ref(0); const bufferedDataRatio = computed(() => { if (!audioEl.value) return 0; @@ -170,6 +257,7 @@ function toggleMute() { } let onceInit = false; +let mediaTickFrameId: number | null = null; let stopAudioElWatch: () => void; function init() { @@ -189,8 +277,12 @@ function init() { } elapsedTimeMs.value = audioEl.value.currentTime * 1000; + + if (audioEl.value.loop !== loop.value) { + loop.value = audioEl.value.loop; + } } - window.requestAnimationFrame(updateMediaTick); + mediaTickFrameId = window.requestAnimationFrame(updateMediaTick); } updateMediaTick(); @@ -228,6 +320,14 @@ watch(volume, (to) => { if (audioEl.value) audioEl.value.volume = to; }); +watch(speed, (to) => { + if (audioEl.value) audioEl.value.playbackRate = to; +}); + +watch(loop, (to) => { + if (audioEl.value) audioEl.value.loop = to; +}); + onMounted(() => { init(); }); @@ -246,6 +346,10 @@ onDeactivated(() => { hide.value = (defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.audio.isSensitive && defaultStore.state.nsfw !== 'ignore'); stopAudioElWatch(); onceInit = false; + if (mediaTickFrameId) { + window.cancelAnimationFrame(mediaTickFrameId); + mediaTickFrameId = null; + } }); </script> @@ -256,6 +360,10 @@ onDeactivated(() => { border: .5px solid var(--divider); border-radius: var(--radius); overflow: clip; + + &:focus { + outline: none; + } } .sensitive { @@ -361,4 +469,15 @@ onDeactivated(() => { } } } + +.nativeAudioContainer { + display: flex; + align-items: center; + padding: 6px; +} + +.nativeAudio { + display: block; + width: 100%; +} </style> diff --git a/packages/frontend/src/components/MkMediaImage.vue b/packages/frontend/src/components/MkMediaImage.vue index 3f9cff8b71..f1fb4f5b44 100644 --- a/packages/frontend/src/components/MkMediaImage.vue +++ b/packages/frontend/src/components/MkMediaImage.vue @@ -60,7 +60,7 @@ import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; -import { iAmModerator } from '@/account.js'; +import { $i, iAmModerator } from '@/account.js'; const props = withDefaults(defineProps<{ image: Misskey.entities.DriveFile; @@ -115,6 +115,13 @@ function showMenu(ev: MouseEvent) { action: () => { os.apiWithDialog('drive/files/update', { fileId: props.image.id, isSensitive: true }); }, + }] : []), ...($i?.id === props.image.userId ? [{ + type: 'divider' as const, + }, { + type: 'link' as const, + text: i18n.ts._fileViewer.title, + icon: 'ph ph-info', + to: `/my/drive/file/${props.image.id}`, }] : [])], ev.currentTarget ?? ev.target); } diff --git a/packages/frontend/src/components/MkMediaVideo.vue b/packages/frontend/src/components/MkMediaVideo.vue index 7c14ade130..dbf76bc33d 100644 --- a/packages/frontend/src/components/MkMediaVideo.vue +++ b/packages/frontend/src/components/MkMediaVideo.vue @@ -6,6 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div ref="playerEl" + v-hotkey="keymap" + tabindex="0" :class="[ $style.videoContainer, controlsShowing && $style.active, @@ -14,6 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only @mouseover="onMouseOver" @mouseleave="onMouseLeave" @contextmenu.stop + @keydown.stop > <button v-if="hide" :class="$style.hidden" @click="hide = false"> <div :class="$style.hiddenTextWrapper"> @@ -22,7 +25,28 @@ SPDX-License-Identifier: AGPL-3.0-only <span style="display: block;">{{ i18n.ts.clickToShow }}</span> </div> </button> - <div v-else :class="$style.videoRoot" @click.self="togglePlayPause"> + + <div v-else-if="defaultStore.reactiveState.useNativeUIForVideoAudioPlayer.value" :class="$style.videoRoot"> + <video + ref="videoEl" + :class="$style.video" + :poster="video.thumbnailUrl ?? undefined" + :title="video.comment ?? undefined" + :alt="video.comment" + preload="metadata" + controls + @keydown.prevent + > + <source :src="video.url"> + </video> + <i class="ph-eye-closed ph-bold ph-lg" :class="$style.hide" @click="hide = true"></i> + <div :class="$style.indicators"> + <div v-if="video.comment" :class="$style.indicator">ALT</div> + <div v-if="video.isSensitive" :class="$style.indicator" style="color: var(--warn);" :title="i18n.ts.sensitive"><i class="ph-warning ph-bold ph-lg"></i></div> + </div> + </div> + + <div v-else :class="$style.videoRoot"> <video ref="videoEl" :class="$style.video" @@ -31,6 +55,8 @@ SPDX-License-Identifier: AGPL-3.0-only :alt="video.comment" preload="metadata" playsinline + @keydown.prevent + @click.self="togglePlayPause" > <source :src="video.url"> </video> @@ -97,12 +123,46 @@ import * as os from '@/os.js'; import { isFullscreenNotSupported } from '@/scripts/device-kind.js'; import hasAudio from '@/scripts/media-has-audio.js'; import MkMediaRange from '@/components/MkMediaRange.vue'; -import { iAmModerator } from '@/account.js'; +import { $i, iAmModerator } from '@/account.js'; const props = defineProps<{ video: Misskey.entities.DriveFile; }>(); +const keymap = { + 'up': () => { + if (hasFocus() && videoEl.value) { + volume.value = Math.min(volume.value + 0.1, 1); + } + }, + 'down': () => { + if (hasFocus() && videoEl.value) { + volume.value = Math.max(volume.value - 0.1, 0); + } + }, + 'left': () => { + if (hasFocus() && videoEl.value) { + videoEl.value.currentTime = Math.max(videoEl.value.currentTime - 5, 0); + } + }, + 'right': () => { + if (hasFocus() && videoEl.value) { + videoEl.value.currentTime = Math.min(videoEl.value.currentTime + 5, videoEl.value.duration); + } + }, + 'space': () => { + if (hasFocus()) { + togglePlayPause(); + } + }, +}; + +// PlayerElもしくはその子要素にフォーカスがあるかどうか +function hasFocus() { + if (!playerEl.value) return false; + return playerEl.value === document.activeElement || playerEl.value.contains(document.activeElement); +} + // eslint-disable-next-line vue/no-setup-props-destructure const hide = ref((defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.video.isSensitive && defaultStore.state.nsfw !== 'ignore')); @@ -115,6 +175,35 @@ function showMenu(ev: MouseEvent) { menu = [ // TODO: 再生キューに追加 { + type: 'switch', + text: i18n.ts._mediaControls.loop, + icon: 'ph ph-repeat', + ref: loop, + }, + { + type: 'radio', + text: i18n.ts._mediaControls.playbackRate, + icon: 'ph ph-gauge', + ref: speed, + options: { + '0.25x': 0.25, + '0.5x': 0.5, + '0.75x': 0.75, + '1.0x': 1, + '1.25x': 1.25, + '1.5x': 1.5, + '2.0x': 2, + }, + }, + ...(document.pictureInPictureEnabled ? [{ + text: i18n.ts._mediaControls.pip, + icon: 'ph ph-picture-in-picture', + action: togglePictureInPicture, + }] : []), + { + type: 'divider', + }, + { text: i18n.ts.hide, icon: 'ph-eye-closed ph-bold ph-lg', action: () => { @@ -125,8 +214,6 @@ function showMenu(ev: MouseEvent) { if (iAmModerator) { menu.push({ - type: 'divider', - }, { text: props.video.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive, icon: props.video.isSensitive ? 'ph-eye ph-bold ph-lg' : 'ph-eye-slash ph-bold ph-lg', danger: true, @@ -134,6 +221,17 @@ function showMenu(ev: MouseEvent) { }); } + if ($i?.id === props.video.userId) { + menu.push({ + type: 'divider', + }, { + type: 'link' as const, + text: i18n.ts._fileViewer.title, + icon: 'ph ph-info', + to: `/my/drive/file/${props.video.id}`, + }); + } + menuShowing.value = true; os.popupMenu(menu, ev.currentTarget ?? ev.target, { align: 'right', @@ -180,6 +278,8 @@ const rangePercent = computed({ }, }); const volume = ref(.25); +const speed = ref(1); +const loop = ref(false); // TODO: ドライブファイルのフラグに置き換える const bufferedEnd = ref(0); const bufferedDataRatio = computed(() => { if (!videoEl.value) return 0; @@ -237,6 +337,16 @@ function toggleFullscreen() { } } +function togglePictureInPicture() { + if (videoEl.value) { + if (document.pictureInPictureElement) { + document.exitPictureInPicture(); + } else { + videoEl.value.requestPictureInPicture(); + } + } +} + function toggleMute() { if (volume.value === 0) { volume.value = .25; @@ -246,6 +356,7 @@ function toggleMute() { } let onceInit = false; +let mediaTickFrameId: number | null = null; let stopVideoElWatch: () => void; function init() { @@ -265,8 +376,12 @@ function init() { } elapsedTimeMs.value = videoEl.value.currentTime * 1000; + + if (videoEl.value.loop !== loop.value) { + loop.value = videoEl.value.loop; + } } - window.requestAnimationFrame(updateMediaTick); + mediaTickFrameId = window.requestAnimationFrame(updateMediaTick); } updateMediaTick(); @@ -310,6 +425,14 @@ watch(volume, (to) => { if (videoEl.value) videoEl.value.volume = to; }); +watch(speed, (to) => { + if (videoEl.value) videoEl.value.playbackRate = to; +}); + +watch(loop, (to) => { + if (videoEl.value) videoEl.value.loop = to; +}); + watch(hide, (to) => { if (to && isFullscreen.value) { document.exitFullscreen(); @@ -335,6 +458,10 @@ onDeactivated(() => { hide.value = (defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.video.isSensitive && defaultStore.state.nsfw !== 'ignore'); stopVideoElWatch(); onceInit = false; + if (mediaTickFrameId) { + window.cancelAnimationFrame(mediaTickFrameId); + mediaTickFrameId = null; + } }); </script> @@ -343,6 +470,10 @@ onDeactivated(() => { container-type: inline-size; position: relative; overflow: clip; + + &:focus { + outline: none; + } } .sensitive { @@ -406,7 +537,7 @@ onDeactivated(() => { font: inherit; color: inherit; cursor: pointer; - padding: 120px 0; + padding: 60px 0; display: flex; align-items: center; justify-content: center; diff --git a/packages/frontend/src/components/MkMention.vue b/packages/frontend/src/components/MkMention.vue index 942c23a145..80a1b68459 100644 --- a/packages/frontend/src/components/MkMention.vue +++ b/packages/frontend/src/components/MkMention.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkA v-user-preview="canonical" :class="[$style.root, { [$style.isMe]: isMe }]" :to="url" :style="{ background: bgCss }"> +<MkA v-user-preview="canonical" :class="[$style.root, { [$style.isMe]: isMe }]" :to="url" :style="{ background: bgCss }" :behavior="navigationBehavior"> <img :class="$style.icon" :src="avatarUrl" alt=""> <span> <span>@{{ username }}</span> @@ -21,10 +21,12 @@ import { host as localHost } from '@/config.js'; import { $i } from '@/account.js'; import { defaultStore } from '@/store.js'; import { getStaticImageUrl } from '@/scripts/media-proxy.js'; +import { MkABehavior } from '@/components/global/MkA.vue'; const props = defineProps<{ username: string; host: string; + navigationBehavior?: MkABehavior; }>(); const canonical = props.host === localHost ? `@${props.username}` : `@${props.username}@${toUnicode(props.host)}`; diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue index 8395879d02..6ced2fecc2 100644 --- a/packages/frontend/src/components/MkMenu.vue +++ b/packages/frontend/src/components/MkMenu.vue @@ -42,9 +42,26 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </button> <button v-else-if="item.type === 'switch'" role="menuitemcheckbox" :tabindex="i" class="_button" :class="[$style.item, $style.switch, { [$style.switchDisabled]: item.disabled } ]" @click="switchItem(item)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> - <MkSwitchButton :class="$style.switchButton" :checked="item.ref" :disabled="item.disabled" @toggle="switchItem(item)"/> + <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i> + <MkSwitchButton v-else :class="$style.switchButton" :checked="item.ref" :disabled="item.disabled" @toggle="switchItem(item)"/> + <div :class="$style.item_content"> + <span :class="[$style.item_content_text, { [$style.switchText]: !item.icon }]">{{ item.text }}</span> + <MkSwitchButton v-if="item.icon" :class="[$style.switchButton, $style.caret]" :checked="item.ref" :disabled="item.disabled" @toggle="switchItem(item)"/> + </div> + </button> + <button v-else-if="item.type === 'radio'" class="_button" role="menuitem" :tabindex="i" :class="[$style.item, $style.parent, { [$style.childShowing]: childShowingItem === item }]" @mouseenter="preferClick ? null : showRadioOptions(item, $event)" @click="!preferClick ? null : showRadioOptions(item, $event)"> + <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]" style="pointer-events: none;"></i> <div :class="$style.item_content"> - <span :class="[$style.item_content_text, $style.switchText]">{{ item.text }}</span> + <span :class="$style.item_content_text" style="pointer-events: none;">{{ item.text }}</span> + <span :class="$style.caret" style="pointer-events: none;"><i class="ph-caret-right ph-bold ph-lg"></i></span> + </div> + </button> + <button v-else-if="item.type === 'radioOption'" :tabindex="i" class="_button" role="menuitem" :class="[$style.item, { [$style.radioActive]: item.active }]" @click="clicked(item.action, $event, false)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> + <div :class="$style.icon"> + <span :class="[$style.radio, { [$style.radioChecked]: item.active }]"></span> + </div> + <div :class="$style.item_content"> + <span :class="$style.item_content_text">{{ item.text }}</span> </div> </button> <button v-else-if="item.type === 'parent'" class="_button" role="menuitem" :tabindex="i" :class="[$style.item, $style.parent, { [$style.childShowing]: childShowingItem === item }]" @mouseenter="preferClick ? null : showChildren(item, $event)" @click="!preferClick ? null : showChildren(item, $event)"> @@ -77,7 +94,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { ComputedRef, computed, defineAsyncComponent, isRef, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'; import { focusPrev, focusNext } from '@/scripts/focus.js'; import MkSwitchButton from '@/components/MkSwitch.button.vue'; -import { MenuItem, InnerMenuItem, MenuPending, MenuAction, MenuSwitch, MenuParent } from '@/types/menu.js'; +import { MenuItem, InnerMenuItem, MenuPending, MenuAction, MenuSwitch, MenuRadio, MenuRadioOption, MenuParent } from '@/types/menu.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { isTouchUsing } from '@/scripts/touch.js'; @@ -168,6 +185,31 @@ function onItemMouseLeave(item) { if (childCloseTimer) window.clearTimeout(childCloseTimer); } +async function showRadioOptions(item: MenuRadio, ev: MouseEvent) { + const children: MenuItem[] = Object.keys(item.options).map<MenuRadioOption>(key => { + const value = item.options[key]; + return { + type: 'radioOption', + text: key, + action: () => { + item.ref = value; + }, + active: computed(() => item.ref === value), + }; + }); + + if (props.asDrawer) { + os.popupMenu(children, ev.currentTarget ?? ev.target).finally(() => { + emit('close'); + }); + emit('hide'); + } else { + childTarget.value = (ev.currentTarget ?? ev.target) as HTMLElement; + childMenu.value = children; + childShowingItem.value = item; + } +} + async function showChildren(item: MenuParent, ev: MouseEvent) { const children: MenuItem[] = await (async () => { if (childrenCache.has(item)) { @@ -196,8 +238,10 @@ async function showChildren(item: MenuParent, ev: MouseEvent) { } } -function clicked(fn: MenuAction, ev: MouseEvent) { +function clicked(fn: MenuAction, ev: MouseEvent, doClose = true) { fn(ev); + + if (!doClose) return; close(true); } @@ -350,6 +394,15 @@ onBeforeUnmount(() => { } } + &.radioActive { + color: var(--accent) !important; + opacity: 1; + + &:before { + background-color: var(--accentedBg) !important; + } + } + &:not(:active):focus-visible { box-shadow: 0 0 0 2px var(--focus) inset; } @@ -417,11 +470,11 @@ onBeforeUnmount(() => { .switchButton { margin-left: -2px; + --height: 1.35em; } .switchText { margin-left: 8px; - margin-top: 2px; overflow: hidden; text-overflow: ellipsis; } @@ -461,4 +514,32 @@ onBeforeUnmount(() => { margin: 8px 0; border-top: solid 0.5px var(--divider); } + +.radio { + display: inline-block; + position: relative; + width: 1em; + height: 1em; + vertical-align: -.125em; + border-radius: 50%; + border: solid 2px var(--divider); + background-color: var(--panel); + + &.radioChecked { + border-color: var(--accent); + + &::after { + content: ""; + display: block; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 50%; + height: 50%; + border-radius: 50%; + background-color: var(--accent); + } + } +} </style> diff --git a/packages/frontend/src/components/MkMfmWindow.vue b/packages/frontend/src/components/MkMfmWindow.vue index a742ad184c..c599531ec5 100644 --- a/packages/frontend/src/components/MkMfmWindow.vue +++ b/packages/frontend/src/components/MkMfmWindow.vue @@ -1,3 +1,8 @@ +<!-- +SPDX-FileCopyrightText: marie and other Sharkey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + <template> <MkWindow ref="window" diff --git a/packages/frontend/src/components/MkModPlayer.vue b/packages/frontend/src/components/MkModPlayer.vue index 75053cbc37..58a96cfb63 100644 --- a/packages/frontend/src/components/MkModPlayer.vue +++ b/packages/frontend/src/components/MkModPlayer.vue @@ -1,3 +1,8 @@ +<!-- +SPDX-FileCopyrightText: marie and other Sharkey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + <template> <div v-if="hide" class="mod-player-disabled" @click="toggleVisible()"> <div> diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue index 40e67fb4e0..9e69ab2207 100644 --- a/packages/frontend/src/components/MkModal.vue +++ b/packages/frontend/src/components/MkModal.vue @@ -175,8 +175,8 @@ const align = () => { let left; let top; - const x = srcRect.left + (fixed.value ? 0 : window.pageXOffset); - const y = srcRect.top + (fixed.value ? 0 : window.pageYOffset); + const x = srcRect.left + (fixed.value ? 0 : window.scrollX); + const y = srcRect.top + (fixed.value ? 0 : window.scrollY); if (props.anchor.x === 'center') { left = x + (props.src.offsetWidth / 2) - (width / 2); @@ -220,24 +220,24 @@ const align = () => { } } else { // 画面から横にはみ出る場合 - if (left + width - window.pageXOffset > (window.innerWidth - SCROLLBAR_THICKNESS)) { - left = (window.innerWidth - SCROLLBAR_THICKNESS) - width + window.pageXOffset - 1; + if (left + width - window.scrollX > (window.innerWidth - SCROLLBAR_THICKNESS)) { + left = (window.innerWidth - SCROLLBAR_THICKNESS) - width + window.scrollX - 1; } - const underSpace = ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN) - (top - window.pageYOffset); + const underSpace = ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN) - (top - window.scrollY); const upperSpace = (srcRect.top - MARGIN); // 画面から縦にはみ出る場合 - if (top + height - window.pageYOffset > ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN)) { + if (top + height - window.scrollY > ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN)) { if (props.noOverlap && props.anchor.x === 'center') { if (underSpace >= (upperSpace / 3)) { maxHeight.value = underSpace; } else { maxHeight.value = upperSpace; - top = window.pageYOffset + ((upperSpace + MARGIN) - height); + top = window.scrollY + ((upperSpace + MARGIN) - height); } } else { - top = ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN) - height + window.pageYOffset - 1; + top = ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN) - height + window.scrollY - 1; } } else { maxHeight.value = underSpace; @@ -255,15 +255,15 @@ const align = () => { let transformOriginX = 'center'; let transformOriginY = 'center'; - if (top >= srcRect.top + props.src.offsetHeight + (fixed.value ? 0 : window.pageYOffset)) { + if (top >= srcRect.top + props.src.offsetHeight + (fixed.value ? 0 : window.scrollY)) { transformOriginY = 'top'; - } else if ((top + height) <= srcRect.top + (fixed.value ? 0 : window.pageYOffset)) { + } else if ((top + height) <= srcRect.top + (fixed.value ? 0 : window.scrollY)) { transformOriginY = 'bottom'; } - if (left >= srcRect.left + props.src.offsetWidth + (fixed.value ? 0 : window.pageXOffset)) { + if (left >= srcRect.left + props.src.offsetWidth + (fixed.value ? 0 : window.scrollX)) { transformOriginX = 'left'; - } else if ((left + width) <= srcRect.left + (fixed.value ? 0 : window.pageXOffset)) { + } else if ((left + width) <= srcRect.left + (fixed.value ? 0 : window.scrollX)) { transformOriginX = 'right'; } @@ -276,8 +276,11 @@ const align = () => { const onOpened = () => { emit('opened'); + // NOTE: Chromatic テストの際に undefined になる場合がある + if (content.value == null) return; + // モーダルコンテンツにマウスボタンが押され、コンテンツ外でマウスボタンが離されたときにモーダルバックグラウンドクリックと判定させないためにマウスイベントを監視しフラグ管理する - const el = content.value!.children[0]; + const el = content.value.children[0]; el.addEventListener('mousedown', ev => { contentClicking = true; window.addEventListener('mouseup', ev => { diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 7e6e6707b8..fe85307a52 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -95,7 +95,9 @@ SPDX-License-Identifier: AGPL-3.0-only <MkMediaList :mediaList="appearNote.files" @click.stop/> </div> <MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll" @click.stop/> - <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview" @click.stop/> + <div v-if="isEnabledUrlPreview"> + <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview" @click.stop/> + </div> <div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div> <button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click.stop @click="collapsed = false"> <span :class="$style.collapsedLabel">{{ i18n.ts.showMore }}</span> @@ -107,15 +109,15 @@ SPDX-License-Identifier: AGPL-3.0-only <MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ph-television ph-bold ph-lg"></i> {{ appearNote.channel.name }}</MkA> </bdi> </div> - <MkReactionsViewer :note="appearNote" :maxNumber="16" @click.stop @mockUpdateMyReaction="emitUpdReaction"> + <MkReactionsViewer v-if="appearNote.reactionAcceptance !== 'likeOnly'" :note="appearNote" :maxNumber="16" @click.stop @mockUpdateMyReaction="emitUpdReaction"> <template #more> - <div :class="$style.reactionOmitted">{{ i18n.ts.more }}</div> + <MkA :to="`/notes/${appearNote.id}/reactions`" :class="[$style.reactionOmitted]">{{ i18n.ts.more }}</MkA> </template> </MkReactionsViewer> <footer :class="$style.footer"> <button :class="$style.footerButton" class="_button" @click.stop @click="reply()"> <i class="ph-arrow-u-up-left ph-bold ph-lg"></i> - <p v-if="appearNote.repliesCount > 0" :class="$style.footerButtonCount">{{ appearNote.repliesCount }}</p> + <p v-if="appearNote.repliesCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.repliesCount) }}</p> </button> <button v-if="canRenote" @@ -127,7 +129,7 @@ SPDX-License-Identifier: AGPL-3.0-only @mousedown="renoted ? undoRenote(appearNote) : boostVisibility()" > <i class="ph-rocket-launch ph-bold ph-lg"></i> - <p v-if="appearNote.renoteCount > 0" :class="$style.footerButtonCount">{{ appearNote.renoteCount }}</p> + <p v-if="appearNote.renoteCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.renoteCount) }}</p> </button> <button v-else :class="$style.footerButton" class="_button" disabled> <i class="ph-prohibit ph-bold ph-lg"></i> @@ -145,12 +147,12 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-if="appearNote.myReaction == null && appearNote.reactionAcceptance !== 'likeOnly'" ref="likeButton" :class="$style.footerButton" class="_button" @click.stop @click="like()"> <i class="ph-heart ph-bold ph-lg"></i> </button> - <button v-if="appearNote.myReaction == null" ref="reactButton" :class="$style.footerButton" class="_button" @mousedown="react()"> - <i v-if="appearNote.reactionAcceptance === 'likeOnly'" class="ph-heart ph-bold ph-lg"></i> + <button ref="reactButton" :class="$style.footerButton" class="_button" @click="toggleReact()" @click.stop> + <i v-if="appearNote.reactionAcceptance === 'likeOnly' && appearNote.myReaction != null" class="ph-heart ph-bold ph-lg" style="color: var(--eventReactionHeart);"></i> + <i v-else-if="appearNote.myReaction != null" class="ph-minus ph-bold ph-lg" style="color: var(--accent);"></i> + <i v-else-if="appearNote.reactionAcceptance === 'likeOnly'" class="ph-heart ph-bold ph-lg"></i> <i v-else class="ph-smiley ph-bold ph-lg"></i> - </button> - <button v-if="appearNote.myReaction != null" ref="reactButton" :class="$style.footerButton" class="_button" @click.stop @click="undoReact(appearNote)"> - <i class="ph-minus ph-bold ph-lg"></i> + <p v-if="(appearNote.reactionAcceptance === 'likeOnly' || defaultStore.state.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.reactionCount) }}</p> </button> <button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown="clip()"> <i class="ph-paperclip ph-bold ph-lg"></i> @@ -194,6 +196,7 @@ import MkNoteSub from '@/components/MkNoteSub.vue'; import MkNoteHeader from '@/components/MkNoteHeader.vue'; import MkNoteSimple from '@/components/MkNoteSimple.vue'; import MkReactionsViewer from '@/components/MkReactionsViewer.vue'; +import MkReactionsViewerDetails from '@/components/MkReactionsViewer.details.vue'; import MkMediaList from '@/components/MkMediaList.vue'; import MkCwButton from '@/components/MkCwButton.vue'; import MkPoll from '@/components/MkPoll.vue'; @@ -205,9 +208,10 @@ import { pleaseLogin } from '@/scripts/please-login.js'; import { focusPrev, focusNext } from '@/scripts/focus.js'; import { checkWordMute } from '@/scripts/check-word-mute.js'; import { userPage } from '@/filters/user.js'; +import number from '@/filters/number.js'; import * as os from '@/os.js'; import * as sound from '@/scripts/sound.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js'; import { defaultStore, noteViewInterruptors } from '@/store.js'; import { reactionPicker } from '@/scripts/reaction-picker.js'; import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js'; @@ -227,6 +231,7 @@ import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; import { shouldCollapsed } from '@/scripts/collapsed.js'; import { useRouter } from '@/router/supplier.js'; import { boostMenuItems, type Visibility } from '@/scripts/boost-quote.js'; +import { isEnabledUrlPreview } from '@/instance.js'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; @@ -280,6 +285,7 @@ if (noteViewInterruptors.length > 0) { const isRenote = ( note.value.renote != null && + note.value.reply == null && note.value.text == null && note.value.cw == null && note.value.fileIds && note.value.fileIds.length === 0 && @@ -315,7 +321,7 @@ const renoteCollapsed = ref( defaultStore.state.collapseRenotes && isRenote && ( ($i && ($i.id === note.value.userId || $i.id === appearNote.value.userId)) || // `||` must be `||`! See https://github.com/misskey-dev/misskey/issues/13131 (appearNote.value.myReaction != null) - ) + ), ); const inReplyToCollapsed = ref(defaultStore.state.collapseNotesRepliedTo); const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null); @@ -419,6 +425,28 @@ if (!props.mock) { renoted.value = res.length > 0; }); } + + if (appearNote.value.reactionAcceptance === 'likeOnly') { + useTooltip(reactButton, async (showing) => { + const reactions = await misskeyApiGet('notes/reactions', { + noteId: appearNote.value.id, + limit: 10, + _cacheKey_: appearNote.value.reactionCount, + }); + + const users = reactions.map(x => x.user); + + if (users.length < 1) return; + + os.popup(MkReactionsViewerDetails, { + showing, + reaction: '❤️', + users, + count: appearNote.value.reactionCount, + targetElement: reactButton.value!, + }, {}, 'closed'); + }); + } } function boostVisibility() { @@ -643,6 +671,14 @@ function undoRenote(note) : void { } } +function toggleReact() { + if (appearNote.value.myReaction == null) { + react(); + } else { + undoReact(appearNote.value); + } +} + function onContextmenu(ev: MouseEvent): void { if (props.mock) { return; @@ -1248,10 +1284,9 @@ function emitUpdReaction(emoji: string, delta: number) { .reactionOmitted { display: inline-block; - height: 32px; - margin: 2px; - padding: 0 6px; + margin-left: 8px; opacity: .8; + font-size: 95%; } .clickToOpen { diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index f9fb6066c8..f0b1ca82a4 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -69,7 +69,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.noteContent"> <p v-if="appearNote.cw != null" :class="$style.cw"> <Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :isBlock="true" :author="appearNote.user" :nyaize="'respect'"/> - <MkCwButton v-model="showContent" :text="appearNote.text" :files="appearNote.files" :poll="appearNote.poll"/> + <MkCwButton v-model="showContent" :text="appearNote.text" :renote="appearNote.renote" :files="appearNote.files" :poll="appearNote.poll"/> </p> <div v-show="appearNote.cw == null || showContent"> <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> @@ -100,7 +100,9 @@ SPDX-License-Identifier: AGPL-3.0-only <MkMediaList :mediaList="appearNote.files"/> </div> <MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll"/> - <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" style="margin-top: 6px;"/> + <div v-if="isEnabledUrlPreview"> + <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" style="margin-top: 6px;"/> + </div> <div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote" :expandAllCws="props.expandAllCws"/></div> </div> <MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ph-television ph-bold ph-lg"></i> {{ appearNote.channel.name }}</MkA> @@ -114,10 +116,10 @@ SPDX-License-Identifier: AGPL-3.0-only <MkTime :time="appearNote.createdAt" mode="detail" colored/> </MkA> </div> - <MkReactionsViewer ref="reactionsViewer" :note="appearNote"/> + <MkReactionsViewer v-if="appearNote.reactionAcceptance !== 'likeOnly'" ref="reactionsViewer" :note="appearNote"/> <button class="_button" :class="$style.noteFooterButton" @click="reply()"> <i class="ph-arrow-u-up-left ph-bold ph-lg"></i> - <p v-if="appearNote.repliesCount > 0" :class="$style.noteFooterButtonCount">{{ appearNote.repliesCount }}</p> + <p v-if="appearNote.repliesCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.repliesCount) }}</p> </button> <button v-if="canRenote" @@ -128,7 +130,7 @@ SPDX-License-Identifier: AGPL-3.0-only @mousedown="renoted ? undoRenote() : boostVisibility()" > <i class="ph-rocket-launch ph-bold ph-lg"></i> - <p v-if="appearNote.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ appearNote.renoteCount }}</p> + <p v-if="appearNote.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.renoteCount) }}</p> </button> <button v-else class="_button" :class="$style.noteFooterButton" disabled> <i class="ph-prohibit ph-bold ph-lg"></i> @@ -145,12 +147,12 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-if="appearNote.myReaction == null && appearNote.reactionAcceptance !== 'likeOnly'" ref="likeButton" :class="$style.noteFooterButton" class="_button" @mousedown="like()"> <i class="ph-heart ph-bold ph-lg"></i> </button> - <button v-if="appearNote.myReaction == null" ref="reactButton" :class="$style.noteFooterButton" class="_button" @mousedown="react()"> - <i v-if="appearNote.reactionAcceptance === 'likeOnly'" class="ph-heart ph-bold ph-lg"></i> + <button ref="reactButton" :class="$style.noteFooterButton" class="_button" @click="toggleReact()"> + <i v-if="appearNote.reactionAcceptance === 'likeOnly' && appearNote.myReaction != null" class="ph-heart ph-bold ph-lg" style="color: var(--eventReactionHeart);"></i> + <i v-else-if="appearNote.myReaction != null" class="ph-minus ph-bold ph-lg" style="color: var(--accent);"></i> + <i v-else-if="appearNote.reactionAcceptance === 'likeOnly'" class="ph-heart ph-bold ph-lg"></i> <i v-else class="ph-smiley ph-bold ph-lg"></i> - </button> - <button v-if="appearNote.myReaction != null" ref="reactButton" class="_button" :class="[$style.noteFooterButton, $style.reacted]" @click="undoReact(appearNote)"> - <i class="ph-minus ph-bold ph-lg"></i> + <p v-if="(appearNote.reactionAcceptance === 'likeOnly' || defaultStore.state.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.reactionCount) }}</p> </button> <button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown="clip()"> <i class="ph-paperclip ph-bold ph-lg"></i> @@ -227,6 +229,7 @@ import * as Misskey from 'misskey-js'; import MkNoteSub from '@/components/MkNoteSub.vue'; import MkNoteSimple from '@/components/MkNoteSimple.vue'; import MkReactionsViewer from '@/components/MkReactionsViewer.vue'; +import MkReactionsViewerDetails from '@/components/MkReactionsViewer.details.vue'; import MkMediaList from '@/components/MkMediaList.vue'; import MkCwButton from '@/components/MkCwButton.vue'; import MkPoll from '@/components/MkPoll.vue'; @@ -237,8 +240,9 @@ import { pleaseLogin } from '@/scripts/please-login.js'; import { checkWordMute } from '@/scripts/check-word-mute.js'; import { userPage } from '@/filters/user.js'; import { notePage } from '@/filters/note.js'; +import number from '@/filters/number.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js'; import * as sound from '@/scripts/sound.js'; import { defaultStore, noteViewInterruptors } from '@/store.js'; import { reactionPicker } from '@/scripts/reaction-picker.js'; @@ -259,11 +263,15 @@ import MkPagination, { type Paging } from '@/components/MkPagination.vue'; import MkReactionIcon from '@/components/MkReactionIcon.vue'; import MkButton from '@/components/MkButton.vue'; import { boostMenuItems, type Visibility } from '@/scripts/boost-quote.js'; +import { isEnabledUrlPreview } from '@/instance.js'; -const props = defineProps<{ +const props = withDefaults(defineProps<{ note: Misskey.entities.Note; expandAllCws?: boolean; -}>(); + initialTab: string; +}>(), { + initialTab: 'replies', +}); const inChannel = inject('inChannel', null); @@ -290,7 +298,9 @@ if (noteViewInterruptors.length > 0) { const isRenote = ( note.value.renote != null && + note.value.reply == null && note.value.text == null && + note.value.cw == null && note.value.fileIds && note.value.fileIds.length === 0 && note.value.poll == null ); @@ -355,7 +365,7 @@ provide('react', (reaction: string) => { }); }); -const tab = ref('replies'); +const tab = ref(props.initialTab); const reactionTabType = ref<string | null>(null); const renotesPagination = computed<Paging>(() => ({ @@ -443,6 +453,28 @@ function boostVisibility() { } } +if (appearNote.value.reactionAcceptance === 'likeOnly') { + useTooltip(reactButton, async (showing) => { + const reactions = await misskeyApiGet('notes/reactions', { + noteId: appearNote.value.id, + limit: 10, + _cacheKey_: appearNote.value.reactionCount, + }); + + const users = reactions.map(x => x.user); + + if (users.length < 1) return; + + os.popup(MkReactionsViewerDetails, { + showing, + reaction: '❤️', + users, + count: appearNote.value.reactionCount, + targetElement: reactButton.value!, + }, {}, 'closed'); + }); +} + function renote(visibility: Visibility, localOnly: boolean = false) { pleaseLogin(); showMovedDialog(); @@ -601,11 +633,11 @@ function like(): void { } } -function undoReact(note): void { - const oldReaction = note.myReaction; +function undoReact(targetNote: Misskey.entities.Note): void { + const oldReaction = targetNote.myReaction; if (!oldReaction) return; misskeyApi('notes/reactions/delete', { - noteId: note.id, + noteId: targetNote.id, }); } @@ -626,6 +658,14 @@ function undoRenote() : void { } } +function toggleReact() { + if (appearNote.value.myReaction == null) { + react(); + } else { + undoReact(appearNote.value); + } +} + function onContextmenu(ev: MouseEvent): void { const isLink = (el: HTMLElement): boolean => { if (el.tagName === 'A') return true; diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue index f62079b9ba..f849e94e93 100644 --- a/packages/frontend/src/components/MkNotification.vue +++ b/packages/frontend/src/components/MkNotification.vue @@ -8,6 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.head"> <MkAvatar v-if="['pollEnded', 'note', 'edited'].includes(notification.type) && notification.note" :class="$style.icon" :user="notification.note.user" link preview/> <MkAvatar v-else-if="['roleAssigned', 'achievementEarned'].includes(notification.type)" :class="$style.icon" :user="$i" link preview/> + <div v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ph-smiley ph-bold ph-lg" style="line-height: 1;"></i></div> <div v-else-if="notification.type === 'reaction:grouped'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ph-smiley ph-bold ph-lg" style="line-height: 1;"></i></div> <div v-else-if="notification.type === 'renote:grouped'" :class="[$style.icon, $style.icon_renoteGroup]"><i class="ph-rocket-launch ph-bold ph-lg" style="line-height: 1;"></i></div> <img v-else-if="notification.type === 'test'" :class="$style.icon" :src="infoImageUrl"/> @@ -60,7 +61,8 @@ SPDX-License-Identifier: AGPL-3.0-only <span v-else-if="notification.type === 'achievementEarned'">{{ i18n.ts._notification.achievementEarned }}</span> <span v-else-if="notification.type === 'test'">{{ i18n.ts._notification.testNotification }}</span> <MkA v-else-if="notification.type === 'follow' || notification.type === 'mention' || notification.type === 'reply' || notification.type === 'renote' || notification.type === 'quote' || notification.type === 'reaction' || notification.type === 'receiveFollowRequest' || notification.type === 'followRequestAccepted'" v-user-preview="notification.user.id" :class="$style.headerName" :to="userPage(notification.user)"><MkUserName :user="notification.user"/></MkA> - <span v-else-if="notification.type === 'reaction:grouped'">{{ i18n.tsx._notification.reactedBySomeUsers({ n: notification.reactions.length }) }}</span> + <span v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'">{{ i18n.tsx._notification.likedBySomeUsers({ n: getActualReactedUsersCount(notification) }) }}</span> + <span v-else-if="notification.type === 'reaction:grouped'">{{ i18n.tsx._notification.reactedBySomeUsers({ n: getActualReactedUsersCount(notification) }) }}</span> <span v-else-if="notification.type === 'renote:grouped'">{{ i18n.tsx._notification.renotedBySomeUsers({ n: notification.users.length }) }}</span> <span v-else-if="notification.type === 'app'">{{ notification.header }}</span> <span v-else-if="notification.type === 'edited'">{{ i18n.ts._notification.edited }}</span> @@ -74,7 +76,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkA> <MkA v-else-if="notification.type === 'renote' || notification.type === 'renote:grouped'" :class="$style.text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note.renote)"> <i class="ph-quotes ph-bold ph-lg" :class="$style.quote"></i> - <Mfm :text="getNoteSummary(notification.note.renote)" :isBlock="true" :plain="true" :nowrap="true" :author="notification.note.renote.user"/> + <Mfm :text="getNoteSummary(notification.note.renote)" :isBlock="true" :plain="true" :nowrap="true" :author="notification.note.renote?.user"/> <i class="ph-quotes ph-bold ph-lg" :class="$style.quote"></i> </MkA> <MkA v-else-if="notification.type === 'reply'" :class="$style.text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)"> @@ -182,6 +184,11 @@ const rejectFollowRequest = () => { followRequestDone.value = true; misskeyApi('following/requests/reject', { userId: props.notification.user.id }); }; + +function getActualReactedUsersCount(notification: Misskey.entities.Notification) { + if (notification.type !== 'reaction:grouped') return 0; + return new Set(notification.reactions.map((reaction) => reaction.user.id)).size; +} </script> <style lang="scss" module> @@ -211,6 +218,7 @@ const rejectFollowRequest = () => { } .icon_reactionGroup, +.icon_reactionGroupHeart, .icon_renoteGroup { display: grid; align-items: center; @@ -223,11 +231,15 @@ const rejectFollowRequest = () => { } .icon_reactionGroup { - background: #e99a0b; + background: var(--eventReaction); +} + +.icon_reactionGroupHeart { + background: var(--eventReactionHeart); } .icon_renoteGroup { - background: #36d298; + background: var(--eventRenote); } .icon_app { @@ -256,49 +268,49 @@ const rejectFollowRequest = () => { .t_follow, .t_followRequestAccepted, .t_receiveFollowRequest { padding: 3px; - background: #36aed2; + background: var(--eventFollow); pointer-events: none; } .t_renote { padding: 3px; - background: #36d298; + background: var(--eventRenote); pointer-events: none; } .t_quote { padding: 3px; - background: #36d298; + background: var(--eventRenote); pointer-events: none; } .t_reply { padding: 3px; - background: #007aff; + background: var(--eventReply); pointer-events: none; } .t_mention { padding: 3px; - background: #88a6b7; + background: var(--eventOther); pointer-events: none; } .t_pollEnded { padding: 3px; - background: #88a6b7; + background: var(--eventOther); pointer-events: none; } .t_achievementEarned { padding: 3px; - background: #cb9a11; + background: var(--eventAchievement); pointer-events: none; } .t_roleAssigned { padding: 3px; - background: #88a6b7; + background: var(--eventOther); pointer-events: none; } diff --git a/packages/frontend/src/components/MkPasswordDialog.vue b/packages/frontend/src/components/MkPasswordDialog.vue index 3c0cdaa786..3a13326946 100644 --- a/packages/frontend/src/components/MkPasswordDialog.vue +++ b/packages/frontend/src/components/MkPasswordDialog.vue @@ -19,18 +19,21 @@ SPDX-License-Identifier: AGPL-3.0-only <div style="margin-top: 16px;">{{ i18n.ts.authenticationRequiredToContinue }}</div> </div> - <div class="_gaps"> - <MkInput ref="passwordInput" v-model="password" :placeholder="i18n.ts.password" type="password" autocomplete="current-password webauthn" :withPasswordToggle="true"> - <template #prefix><i class="ph-password ph-bold ph-lg"></i></template> - </MkInput> + <form @submit.prevent="done"> + <div class="_gaps"> + <MkInput ref="passwordInput" v-model="password" :placeholder="i18n.ts.password" type="password" autocomplete="current-password webauthn" required :withPasswordToggle="true"> + <template #prefix><i class="ph-password ph-bold ph-lg"></i></template> + </MkInput> - <MkInput v-if="$i.twoFactorEnabled" v-model="token" type="text" pattern="^([0-9]{6}|[A-Z0-9]{32})$" autocomplete="one-time-code" :spellcheck="false"> - <template #label>{{ i18n.ts.token }} ({{ i18n.ts['2fa'] }})</template> - <template #prefix><i class="ph-keyhole ph-bold ph-lg"></i></template> - </MkInput> + <MkInput v-if="$i.twoFactorEnabled" v-model="token" type="text" :pattern="isBackupCode ? '^[A-Z0-9]{32}$' :'^[0-9]{6}$'" autocomplete="one-time-code" required :spellcheck="false" :inputmode="isBackupCode ? undefined : 'numeric'"> + <template #label>{{ i18n.ts.token }} ({{ i18n.ts['2fa'] }})</template> + <template #prefix><i v-if="isBackupCode" class="ph-keyhole ph-bold ph-lg"></i><i v-else class="ph-numpad ph-bold ph-lg"></i></template> + <template #caption><button class="_textButton" type="button" @click="isBackupCode = !isBackupCode">{{ isBackupCode ? i18n.ts.useTotp : i18n.ts.useBackupCode }}</button></template> + </MkInput> - <MkButton :disabled="(password ?? '') == '' || ($i.twoFactorEnabled && (token ?? '') == '')" primary rounded style="margin: 0 auto;" @click="done"><i class="ph-lock ph-bold ph-lg-open"></i> {{ i18n.ts.continue }}</MkButton> - </div> + <MkButton :disabled="(password ?? '') == '' || ($i.twoFactorEnabled && (token ?? '') == '')" type="submit" primary rounded style="margin: 0 auto;"><i class="ph-lock ph-bold ph-lg-open"></i> {{ i18n.ts.continue }}</MkButton> + </div> + </form> </MkSpacer> </MkModalWindow> </template> @@ -54,6 +57,7 @@ const emit = defineEmits<{ const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); const passwordInput = shallowRef<InstanceType<typeof MkInput>>(); const password = ref(''); +const isBackupCode = ref(false); const token = ref<string | null>(null); function onClose() { @@ -61,7 +65,7 @@ function onClose() { if (dialog.value) dialog.value.close(); } -function done(res) { +function done() { emit('done', { password: password.value, token: token.value }); if (dialog.value) dialog.value.close(); } diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 85b6073d9d..cfaaeecc34 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -158,6 +158,7 @@ const props = withDefaults(defineProps<{ initialVisibleUsers: () => [], autofocus: true, mock: false, + initialLocalOnly: undefined, }); provide('mock', props.mock); @@ -187,11 +188,11 @@ watch(showPreview, () => defaultStore.set('showPreview', showPreview.value)); const showAddMfmFunction = ref(defaultStore.state.enableQuickAddMfmFunction); watch(showAddMfmFunction, () => defaultStore.set('enableQuickAddMfmFunction', showAddMfmFunction.value)); const cw = ref<string | null>(props.initialCw ?? null); -const localOnly = ref<boolean>(props.initialLocalOnly ?? defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly); -const visibility = ref(props.initialVisibility ?? (defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility) as typeof Misskey.noteVisibilities[number]); +const localOnly = ref(props.initialLocalOnly ?? (defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly)); +const visibility = ref(props.initialVisibility ?? (defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility)); const visibleUsers = ref<Misskey.entities.UserDetailed[]>([]); if (props.initialVisibleUsers) { - props.initialVisibleUsers.forEach(pushVisibleUser); + props.initialVisibleUsers.forEach(u => pushVisibleUser(u)); } const reactionAcceptance = ref(defaultStore.state.reactionAcceptance); const autocomplete = ref(null); @@ -255,7 +256,13 @@ const maxTextLength = computed((): number => { const canPost = computed((): boolean => { return !props.mock && !posting.value && !posted.value && - (1 <= textLength.value || 1 <= files.value.length || !!poll.value || !!props.renote) && + ( + 1 <= textLength.value || + 1 <= files.value.length || + poll.value != null || + props.renote != null || + (props.reply != null && quoteId.value != null) + ) && (textLength.value <= maxTextLength.value) && (!poll.value || poll.value.choices.length >= 2); }); @@ -331,7 +338,7 @@ if (props.reply && ['home', 'followers', 'specified'].includes(props.reply.visib misskeyApi('users/show', { userIds: props.reply.visibleUserIds.filter(uid => uid !== $i.id && uid !== props.reply?.userId), }).then(users => { - users.forEach(pushVisibleUser); + users.forEach(u => pushVisibleUser(u)); }); } @@ -388,7 +395,7 @@ function addMissingMention() { for (const x of extractMentions(ast)) { if (!visibleUsers.value.some(u => (u.username === x.username) && (u.host === x.host))) { misskeyApi('users/show', { username: x.username, host: x.host }).then(user => { - visibleUsers.value.push(user); + pushVisibleUser(user); }); } } @@ -518,6 +525,9 @@ async function toggleLocalOnly() { } localOnly.value = !localOnly.value; + if (defaultStore.state.rememberNoteVisibility) { + defaultStore.set('localOnly', localOnly.value); + } } async function toggleReactionAcceptance() { @@ -608,6 +618,23 @@ async function onPaste(ev: ClipboardEvent) { quoteId.value = paste.substring(url.length).match(/^\/notes\/(.+?)\/?$/)?.[1] ?? null; }); } + + if (paste.length > 1000) { + ev.preventDefault(); + os.confirm({ + type: 'info', + text: i18n.ts.attachAsFileQuestion, + }).then(({ canceled }) => { + if (canceled) { + insertTextAtCursor(textareaEl.value, paste); + return; + } + + const fileName = formatTimeString(new Date(), defaultStore.state.pastedFileName).replace(/{{number}}/g, "0"); + const file = new File([paste], `${fileName}.txt`, { type: "text/plain" }); + upload(file, `${fileName}.txt`); + }); + } } function onDragover(ev) { @@ -679,6 +706,7 @@ function saveDraft() { localOnly: localOnly.value, files: files.value, poll: poll.value, + visibleUserIds: visibility.value === 'specified' ? visibleUsers.value.map(x => x.id) : undefined, }, }; @@ -991,6 +1019,11 @@ onMounted(() => { if (draft.data.poll) { poll.value = draft.data.poll; } + if (draft.data.visibleUserIds) { + misskeyApi('users/show', { userIds: draft.data.visibleUserIds }).then(users => { + users.forEach(u => pushVisibleUser(u)); + }); + } } } diff --git a/packages/frontend/src/components/MkPostFormDialog.vue b/packages/frontend/src/components/MkPostFormDialog.vue index 5260ac2a08..ad990e21db 100644 --- a/packages/frontend/src/components/MkPostFormDialog.vue +++ b/packages/frontend/src/components/MkPostFormDialog.vue @@ -15,7 +15,7 @@ import * as Misskey from 'misskey-js'; import MkModal from '@/components/MkModal.vue'; import MkPostForm from '@/components/MkPostForm.vue'; -const props = defineProps<{ +const props = withDefaults(defineProps<{ reply?: Misskey.entities.Note; renote?: Misskey.entities.Note; channel?: any; // TODO @@ -32,7 +32,9 @@ const props = defineProps<{ fixed?: boolean; autofocus?: boolean; editId?: Misskey.entities.Note["id"]; -}>(); +}>(), { + initialLocalOnly: undefined, +}); const emit = defineEmits<{ (ev: 'closed'): void; diff --git a/packages/frontend/src/components/MkReactionsViewer.vue b/packages/frontend/src/components/MkReactionsViewer.vue index 3d3130cd51..a70ed18d18 100644 --- a/packages/frontend/src/components/MkReactionsViewer.vue +++ b/packages/frontend/src/components/MkReactionsViewer.vue @@ -100,6 +100,9 @@ watch([() => props.note.reactions, () => props.maxNumber], ([newSource, maxNumbe } .root { + display: flex; + flex-wrap: wrap; + align-items: center; margin: 4px -2px 0 -2px; cursor: auto; /* not clickToOpen-able */ diff --git a/packages/frontend/src/components/MkSignin.vue b/packages/frontend/src/components/MkSignin.vue index dc68a99593..6f7994dccb 100644 --- a/packages/frontend/src/components/MkSignin.vue +++ b/packages/frontend/src/components/MkSignin.vue @@ -31,15 +31,15 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="user && user.securityKeys" class="or-hr"> <p class="or-msg">{{ i18n.ts.or }}</p> </div> - <div class="twofa-group totp-group"> - <p style="margin-bottom:0;">{{ i18n.ts['2fa'] }}</p> + <div class="twofa-group totp-group _gaps"> <MkInput v-if="user && user.usePasswordLessLogin" v-model="password" type="password" autocomplete="current-password" :withPasswordToggle="true" required> <template #label>{{ i18n.ts.password }}</template> <template #prefix><i class="ph-lock ph-bold ph-lg"></i></template> </MkInput> - <MkInput v-model="token" type="text" pattern="^([0-9]{6}|[A-Z0-9]{32})$" autocomplete="one-time-code" :spellcheck="false" required> - <template #label>{{ i18n.ts.token }}</template> - <template #prefix><i class="ph-keyhole ph-bold ph-lg"></i></template> + <MkInput v-model="token" type="text" :pattern="isBackupCode ? '^[A-Z0-9]{32}$' :'^[0-9]{6}$'" autocomplete="one-time-code" required :spellcheck="false" :inputmode="isBackupCode ? undefined : 'numeric'"> + <template #label>{{ i18n.ts.token }} ({{ i18n.ts['2fa'] }})</template> + <template #prefix><i v-if="isBackupCode" class="ph-keyhole ph-bold ph-lg"></i><i v-else class="ph-numpad ph-bold ph-lg"></i></template> + <template #caption><button class="_textButton" type="button" @click="isBackupCode = !isBackupCode">{{ isBackupCode ? i18n.ts.useTotp : i18n.ts.useBackupCode }}</button></template> </MkInput> <MkButton type="submit" :disabled="signing" large primary rounded style="margin: 0 auto;">{{ signing ? i18n.ts.loggingIn : i18n.ts.login }}</MkButton> </div> @@ -70,6 +70,7 @@ const password = ref(''); const token = ref(''); const host = ref(toUnicode(configHost)); const totpLogin = ref(false); +const isBackupCode = ref(false); const queryingKey = ref(false); const credentialRequest = ref<CredentialRequestOptions | null>(null); diff --git a/packages/frontend/src/components/MkSignupDialog.rules.stories.impl.ts b/packages/frontend/src/components/MkSignupDialog.rules.stories.impl.ts index fcd1ffde3e..9df3ec0c30 100644 --- a/packages/frontend/src/components/MkSignupDialog.rules.stories.impl.ts +++ b/packages/frontend/src/components/MkSignupDialog.rules.stories.impl.ts @@ -51,13 +51,16 @@ export const Empty = { expect(buttons.at(-1)).toBeEnabled(); }, args: { + // @ts-expect-error serverRules is for test serverRules: [], tosUrl: null, }, decorators: [ (_, context) => ({ setup() { + // @ts-expect-error serverRules is for test instance.serverRules = context.args.serverRules; + // @ts-expect-error tosUrl is for test instance.tosUrl = context.args.tosUrl; onBeforeUnmount(() => { // FIXME: 呼び出されない @@ -76,6 +79,7 @@ export const ServerRulesOnly = { ...Empty, args: { ...Empty.args, + // @ts-expect-error serverRules is for test serverRules: [ 'ルール', ], @@ -85,6 +89,7 @@ export const TOSOnly = { ...Empty, args: { ...Empty.args, + // @ts-expect-error tosUrl is for test tosUrl: 'https://example.com/tos', }, } satisfies StoryObj<typeof MkSignupServerRules>; @@ -92,6 +97,7 @@ export const ServerRulesAndTOS = { ...Empty, args: { ...Empty.args, + // @ts-expect-error serverRules is for test serverRules: ServerRulesOnly.args.serverRules, tosUrl: TOSOnly.args.tosUrl, }, diff --git a/packages/frontend/src/components/MkSwitch.button.vue b/packages/frontend/src/components/MkSwitch.button.vue index 21339d1b4e..f7c413e1d3 100644 --- a/packages/frontend/src/components/MkSwitch.button.vue +++ b/packages/frontend/src/components/MkSwitch.button.vue @@ -41,13 +41,15 @@ const toggle = () => { <style lang="scss" module> .button { + --height: 21px; + position: relative; display: inline-flex; flex-shrink: 0; margin: 0; box-sizing: border-box; - width: 32px; - height: 23px; + width: calc(var(--height) * 1.6); + height: calc(var(--height) + 2px); // 枠線 outline: none; background: var(--switchOffBg); background-clip: content-box; @@ -69,9 +71,10 @@ const toggle = () => { .knob { position: absolute; + box-sizing: border-box; top: 3px; - width: 15px; - height: 15px; + width: calc(var(--height) - 6px); + height: calc(var(--height) - 6px); border-radius: var(--radius-ellipse); transition: all 0.2s ease; @@ -82,7 +85,7 @@ const toggle = () => { } .knobChecked { - left: 12px; + left: calc(calc(100% - var(--height)) + 3px); background: var(--switchOnFg); } </style> diff --git a/packages/frontend/src/components/MkTutorialDialog.Note.vue b/packages/frontend/src/components/MkTutorialDialog.Note.vue index f8139d641e..725cfcdc33 100644 --- a/packages/frontend/src/components/MkTutorialDialog.Note.vue +++ b/packages/frontend/src/components/MkTutorialDialog.Note.vue @@ -74,6 +74,7 @@ const exampleNote = reactive<Misskey.entities.Note>({ reactionAcceptance: null, renoteCount: 0, repliesCount: 1, + reactionCount: 0, reactions: {}, reactionEmojis: {}, fileIds: [], diff --git a/packages/frontend/src/components/MkTutorialDialog.PostNote.vue b/packages/frontend/src/components/MkTutorialDialog.PostNote.vue index 1771559a9b..b0561d4bae 100644 --- a/packages/frontend/src/components/MkTutorialDialog.PostNote.vue +++ b/packages/frontend/src/components/MkTutorialDialog.PostNote.vue @@ -68,6 +68,7 @@ const exampleCWNote = reactive<Misskey.entities.Note>({ reactionAcceptance: null, renoteCount: 0, repliesCount: 1, + reactionCount: 0, reactions: {}, reactionEmojis: {}, fileIds: [], diff --git a/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue b/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue index 4b4e8ea8f8..f155ad7bcb 100644 --- a/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue +++ b/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue @@ -58,6 +58,7 @@ const exampleNote = reactive<Misskey.entities.Note>({ reactionAcceptance: null, renoteCount: 0, repliesCount: 1, + reactionCount: 0, reactions: {}, reactionEmojis: {}, fileIds: ['0000000002'], diff --git a/packages/frontend/src/components/MkUrlPreview.vue b/packages/frontend/src/components/MkUrlPreview.vue index 10ba137b94..2e069fcdd2 100644 --- a/packages/frontend/src/components/MkUrlPreview.vue +++ b/packages/frontend/src/components/MkUrlPreview.vue @@ -152,15 +152,16 @@ requestUrl.hash = ''; window.fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${versatileLang}`) .then(res => { if (!res.ok) { - fetching.value = false; - unknownUrl.value = true; - return; + if (_DEV_) { + console.warn(`[HTTP${res.status}] Failed to fetch url preview`); + } + return null; } return res.json(); }) - .then((info: SummalyResult) => { - if (info.url == null) { + .then((info: SummalyResult | null) => { + if (!info || info.url == null) { fetching.value = false; unknownUrl.value = true; return; diff --git a/packages/frontend/src/components/MkUrlPreviewPopup.vue b/packages/frontend/src/components/MkUrlPreviewPopup.vue index cf75064be7..e972973dba 100644 --- a/packages/frontend/src/components/MkUrlPreviewPopup.vue +++ b/packages/frontend/src/components/MkUrlPreviewPopup.vue @@ -33,8 +33,8 @@ const left = ref(0); onMounted(() => { const rect = props.source.getBoundingClientRect(); - const x = Math.max((rect.left + (props.source.offsetWidth / 2)) - (300 / 2), 6) + window.pageXOffset; - const y = rect.top + props.source.offsetHeight + window.pageYOffset; + const x = Math.max((rect.left + (props.source.offsetWidth / 2)) - (300 / 2), 6) + window.scrollX; + const y = rect.top + props.source.offsetHeight + window.scrollY; top.value = y; left.value = x; diff --git a/packages/frontend/src/components/MkUserPopup.vue b/packages/frontend/src/components/MkUserPopup.vue index cab2afdce5..2aee918114 100644 --- a/packages/frontend/src/components/MkUserPopup.vue +++ b/packages/frontend/src/components/MkUserPopup.vue @@ -118,8 +118,8 @@ onMounted(() => { } const rect = props.source.getBoundingClientRect(); - const x = ((rect.left + (props.source.offsetWidth / 2)) - (300 / 2)) + window.pageXOffset; - const y = rect.top + props.source.offsetHeight + window.pageYOffset; + const x = ((rect.left + (props.source.offsetWidth / 2)) - (300 / 2)) + window.scrollX; + const y = rect.top + props.source.offsetHeight + window.scrollY; top.value = y; left.value = x; diff --git a/packages/frontend/src/components/MkVisitorDashboard.vue b/packages/frontend/src/components/MkVisitorDashboard.vue index f9f16c594e..b902494025 100644 --- a/packages/frontend/src/components/MkVisitorDashboard.vue +++ b/packages/frontend/src/components/MkVisitorDashboard.vue @@ -4,19 +4,19 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div v-if="meta" :class="$style.root"> +<div v-if="instance" :class="$style.root"> <div :class="[$style.main, $style.panel]"> <img :src="instance.iconUrl || '/apple-touch-icon.png'" alt="" :class="$style.mainIcon"/> <button class="_button _acrylic" :class="$style.mainMenu" @click="showMenu"><i class="ph-dots-three ph-bold ph-lg"></i></button> <div :class="$style.mainFg"> <h1 :class="$style.mainTitle"> <!-- 背景色によってはロゴが見えなくなるのでとりあえず無効に --> - <!-- <img class="logo" v-if="meta.logoImageUrl" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span> --> + <!-- <img class="logo" v-if="instance.logoImageUrl" :src="instance.logoImageUrl"><span v-else class="text">{{ instanceName }}</span> --> <span>{{ instanceName }}</span> </h1> <div :class="$style.mainAbout"> <!-- eslint-disable-next-line vue/no-v-html --> - <div v-html="sanitizeHtml(meta.description) || i18n.ts.headlineMisskey"></div> + <div v-html="sanitizeHtml(instance.description) || i18n.ts.headlineMisskey"></div> </div> <div v-if="instance.disableRegistration" :class="$style.mainWarn"> <MkInfo warn>{{ i18n.ts.invitationRequiredToRegister }}</MkInfo> @@ -69,14 +69,10 @@ import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; import MkNumber from '@/components/MkNumber.vue'; import XActiveUsersChart from '@/components/MkVisitorDashboard.ActiveUsersChart.vue'; +import { openInstanceMenu } from '@/ui/_common_/common'; -const meta = ref<Misskey.entities.MetaResponse | null>(null); const stats = ref<Misskey.entities.StatsResponse | null>(null); -misskeyApi('meta', { detail: true }).then(_meta => { - meta.value = _meta; -}); - misskeyApi('stats', {}).then((res) => { stats.value = res; }); @@ -94,49 +90,7 @@ function signup() { } function showMenu(ev) { - os.popupMenu([{ - text: i18n.ts.instanceInfo, - icon: 'ph-info ph-bold ph-lg', - action: () => { - os.pageWindow('/about'); - }, - }, { - text: i18n.ts.aboutMisskey, - icon: 'sk-icons sk-shark ph-bold', - action: () => { - os.pageWindow('/about-sharkey'); - }, - }, { type: 'divider' }, (instance.impressumUrl) ? { - text: i18n.ts.impressum, - icon: 'ph-newspaper-clipping ph-bold ph-lg', - action: () => { - window.open(instance.impressumUrl!, '_blank', 'noopener'); - }, - } : undefined, (instance.tosUrl) ? { - text: i18n.ts.termsOfService, - icon: 'ph-notebook ph-bold ph-lg', - action: () => { - window.open(instance.tosUrl!, '_blank', 'noopener'); - }, - } : undefined, (instance.privacyPolicyUrl) ? { - text: i18n.ts.privacyPolicy, - icon: 'ph-shield ph-bold ph-lg', - action: () => { - window.open(instance.privacyPolicyUrl!, '_blank', 'noopener'); - }, - } : undefined, (instance.donationUrl) ? { - text: i18n.ts.donation, - icon: 'ph-hand-coins ph-bold ph-lg', - action: () => { - window.open(instance.donationUrl, '_blank', 'noopener'); - }, - } : undefined, (!instance.impressumUrl && !instance.tosUrl && !instance.privacyPolicyUrl && !instance.donationUrl) ? undefined : { type: 'divider' }, { - text: i18n.ts.help, - icon: 'ph-question ph-bold ph-lg', - action: () => { - window.open('https://misskey-hub.net/docs/for-users/', '_blank', 'noopener'); - }, - }], ev.currentTarget ?? ev.target); + openInstanceMenu(ev); } function exploreOtherServers() { diff --git a/packages/frontend/src/components/SkApprovalUser.vue b/packages/frontend/src/components/SkApprovalUser.vue index f85944cd04..20059f139d 100644 --- a/packages/frontend/src/components/SkApprovalUser.vue +++ b/packages/frontend/src/components/SkApprovalUser.vue @@ -1,3 +1,8 @@ +<!-- +SPDX-FileCopyrightText: marie and other Sharkey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + <template> <MkFolder :expanded="false"> <template #icon><i class="ph-user ph-bold ph-lg"></i></template> diff --git a/packages/frontend/src/components/SkNote.vue b/packages/frontend/src/components/SkNote.vue index 8f0a5a8f11..a193df4326 100644 --- a/packages/frontend/src/components/SkNote.vue +++ b/packages/frontend/src/components/SkNote.vue @@ -97,7 +97,9 @@ SPDX-License-Identifier: AGPL-3.0-only <MkMediaList :mediaList="appearNote.files" @click.stop/> </div> <MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll" @click.stop/> - <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview" @click.stop/> + <div v-if="isEnabledUrlPreview"> + <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview" @click.stop/> + </div> <div v-if="appearNote.renote" :class="$style.quote"><SkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div> <button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click.stop @click="collapsed = false"> <span :class="$style.collapsedLabel">{{ i18n.ts.showMore }}</span> @@ -108,15 +110,15 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ph-television ph-bold ph-lg"></i> {{ appearNote.channel.name }}</MkA> </div> - <MkReactionsViewer :note="appearNote" :maxNumber="16" @click.stop @mockUpdateMyReaction="emitUpdReaction"> + <MkReactionsViewer v-if="appearNote.reactionAcceptance !== 'likeOnly'" :note="appearNote" :maxNumber="16" @click.stop @mockUpdateMyReaction="emitUpdReaction"> <template #more> - <div :class="$style.reactionOmitted">{{ i18n.ts.more }}</div> + <MkA :to="`/notes/${appearNote.id}/reactions`" :class="[$style.reactionOmitted]">{{ i18n.ts.more }}</MkA> </template> </MkReactionsViewer> <footer :class="$style.footer"> <button :class="$style.footerButton" class="_button" @click.stop @click="reply()"> <i class="ph-arrow-u-up-left ph-bold ph-lg"></i> - <p v-if="appearNote.repliesCount > 0" :class="$style.footerButtonCount">{{ appearNote.repliesCount }}</p> + <p v-if="appearNote.repliesCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.repliesCount) }}</p> </button> <button v-if="canRenote" @@ -128,7 +130,7 @@ SPDX-License-Identifier: AGPL-3.0-only @mousedown="renoted ? undoRenote(appearNote) : boostVisibility()" > <i class="ph-rocket-launch ph-bold ph-lg"></i> - <p v-if="appearNote.renoteCount > 0" :class="$style.footerButtonCount">{{ appearNote.renoteCount }}</p> + <p v-if="appearNote.renoteCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.renoteCount) }}</p> </button> <button v-else :class="$style.footerButton" class="_button" disabled> <i class="ph-prohibit ph-bold ph-lg"></i> @@ -146,12 +148,12 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-if="appearNote.myReaction == null && appearNote.reactionAcceptance !== 'likeOnly'" ref="likeButton" :class="$style.footerButton" class="_button" @click.stop @click="like()"> <i class="ph-heart ph-bold ph-lg"></i> </button> - <button v-if="appearNote.myReaction == null" ref="reactButton" :class="$style.footerButton" class="_button" @mousedown="react()"> - <i v-if="appearNote.reactionAcceptance === 'likeOnly'" class="ph-heart ph-bold ph-lg"></i> + <button ref="reactButton" :class="$style.footerButton" class="_button" @click="toggleReact()" @click.stop> + <i v-if="appearNote.reactionAcceptance === 'likeOnly' && appearNote.myReaction != null" class="ph-heart ph-bold ph-lg" style="color: var(--eventReactionHeart);"></i> + <i v-else-if="appearNote.myReaction != null" class="ph-minus ph-bold ph-lg" style="color: var(--accent);"></i> + <i v-else-if="appearNote.reactionAcceptance === 'likeOnly'" class="ph-heart ph-bold ph-lg"></i> <i v-else class="ph-smiley ph-bold ph-lg"></i> - </button> - <button v-if="appearNote.myReaction != null" ref="reactButton" :class="$style.footerButton" class="_button" @click.stop @click="undoReact(appearNote)"> - <i class="ph-minus ph-bold ph-lg"></i> + <p v-if="(appearNote.reactionAcceptance === 'likeOnly' || defaultStore.state.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.reactionCount) }}</p> </button> <button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown="clip()"> <i class="ph-paperclip ph-bold ph-lg"></i> @@ -195,6 +197,7 @@ import SkNoteSub from '@/components/SkNoteSub.vue'; import SkNoteHeader from '@/components/SkNoteHeader.vue'; import SkNoteSimple from '@/components/SkNoteSimple.vue'; import MkReactionsViewer from '@/components/MkReactionsViewer.vue'; +import MkReactionsViewerDetails from '@/components/MkReactionsViewer.details.vue'; import MkMediaList from '@/components/MkMediaList.vue'; import MkCwButton from '@/components/MkCwButton.vue'; import MkPoll from '@/components/MkPoll.vue'; @@ -205,8 +208,9 @@ import { pleaseLogin } from '@/scripts/please-login.js'; import { focusPrev, focusNext } from '@/scripts/focus.js'; import { checkWordMute } from '@/scripts/check-word-mute.js'; import { userPage } from '@/filters/user.js'; +import number from '@/filters/number.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js'; import * as sound from '@/scripts/sound.js'; import { defaultStore, noteViewInterruptors } from '@/store.js'; import { reactionPicker } from '@/scripts/reaction-picker.js'; @@ -227,6 +231,7 @@ import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; import { shouldCollapsed } from '@/scripts/collapsed.js'; import { useRouter } from '@/router/supplier.js'; import { boostMenuItems, type Visibility } from '@/scripts/boost-quote.js'; +import { isEnabledUrlPreview } from '@/instance.js'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; @@ -280,6 +285,7 @@ if (noteViewInterruptors.length > 0) { const isRenote = ( note.value.renote != null && + note.value.reply == null && note.value.text == null && note.value.cw == null && note.value.fileIds && note.value.fileIds.length === 0 && @@ -419,6 +425,28 @@ if (!props.mock) { renoted.value = res.length > 0; }); } + + if (appearNote.value.reactionAcceptance === 'likeOnly') { + useTooltip(reactButton, async (showing) => { + const reactions = await misskeyApiGet('notes/reactions', { + noteId: appearNote.value.id, + limit: 10, + _cacheKey_: appearNote.value.reactionCount, + }); + + const users = reactions.map(x => x.user); + + if (users.length < 1) return; + + os.popup(MkReactionsViewerDetails, { + showing, + reaction: '❤️', + users, + count: appearNote.value.reactionCount, + targetElement: reactButton.value!, + }, {}, 'closed'); + }); + } } function boostVisibility() { @@ -643,6 +671,14 @@ function undoRenote(note) : void { } } +function toggleReact() { + if (appearNote.value.myReaction == null) { + react(); + } else { + undoReact(appearNote.value); + } +} + function onContextmenu(ev: MouseEvent): void { if (props.mock) { return; @@ -1303,10 +1339,9 @@ function emitUpdReaction(emoji: string, delta: number) { .reactionOmitted { display: inline-block; - height: 32px; - margin: 2px; - padding: 0 6px; + margin-left: 8px; opacity: .8; + font-size: 95%; } .clickToOpen { diff --git a/packages/frontend/src/components/SkNoteDetailed.vue b/packages/frontend/src/components/SkNoteDetailed.vue index 2274793666..7a23d0aa73 100644 --- a/packages/frontend/src/components/SkNoteDetailed.vue +++ b/packages/frontend/src/components/SkNoteDetailed.vue @@ -78,7 +78,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.noteContent"> <p v-if="appearNote.cw != null" :class="$style.cw"> <Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :isBlock="true" :author="appearNote.user" :nyaize="'respect'"/> - <MkCwButton v-model="showContent" :text="appearNote.text" :files="appearNote.files" :poll="appearNote.poll"/> + <MkCwButton v-model="showContent" :text="appearNote.text" :renote="appearNote.renote" :files="appearNote.files" :poll="appearNote.poll"/> </p> <div v-show="appearNote.cw == null || showContent"> <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> @@ -108,7 +108,9 @@ SPDX-License-Identifier: AGPL-3.0-only <MkMediaList :mediaList="appearNote.files"/> </div> <MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll"/> - <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" style="margin-top: 6px;"/> + <div v-if="isEnabledUrlPreview"> + <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" style="margin-top: 6px;"/> + </div> <div v-if="appearNote.renote" :class="$style.quote"><SkNoteSimple :note="appearNote.renote" :class="$style.quoteNote" :expandAllCws="props.expandAllCws"/></div> </div> <MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ph-television ph-bold ph-lg"></i> {{ appearNote.channel.name }}</MkA> @@ -121,11 +123,11 @@ SPDX-License-Identifier: AGPL-3.0-only <MkTime :time="appearNote.createdAt" mode="detail" colored/> </MkA> </div> - <MkReactionsViewer ref="reactionsViewer" :note="appearNote"/> + <MkReactionsViewer v-if="appearNote.reactionAcceptance !== 'likeOnly'" ref="reactionsViewer" :note="appearNote"/> <footer :class="$style.footer"> <button class="_button" :class="$style.noteFooterButton" @click="reply()"> <i class="ph-arrow-u-up-left ph-bold ph-lg"></i> - <p v-if="appearNote.repliesCount > 0" :class="$style.noteFooterButtonCount">{{ appearNote.repliesCount }}</p> + <p v-if="appearNote.repliesCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.repliesCount) }}</p> </button> <button v-if="canRenote" @@ -136,7 +138,7 @@ SPDX-License-Identifier: AGPL-3.0-only @mousedown="renoted ? undoRenote() : boostVisibility()" > <i class="ph-rocket-launch ph-bold ph-lg"></i> - <p v-if="appearNote.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ appearNote.renoteCount }}</p> + <p v-if="appearNote.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.renoteCount) }}</p> </button> <button v-else class="_button" :class="$style.noteFooterButton" disabled> <i class="ph-prohibit ph-bold ph-lg"></i> @@ -153,12 +155,12 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-if="appearNote.myReaction == null && appearNote.reactionAcceptance !== 'likeOnly'" ref="likeButton" :class="$style.noteFooterButton" class="_button" @mousedown="like()"> <i class="ph-heart ph-bold ph-lg"></i> </button> - <button v-if="appearNote.myReaction == null" ref="reactButton" :class="$style.noteFooterButton" class="_button" @mousedown="react()"> - <i v-if="appearNote.reactionAcceptance === 'likeOnly'" class="ph-heart ph-bold ph-lg"></i> + <button ref="reactButton" :class="$style.noteFooterButton" class="_button" @click="toggleReact()"> + <i v-if="appearNote.reactionAcceptance === 'likeOnly' && appearNote.myReaction != null" class="ph-heart ph-bold ph-lg" style="color: var(--eventReactionHeart);"></i> + <i v-else-if="appearNote.myReaction != null" class="ph-minus ph-bold ph-lg" style="color: var(--accent);"></i> + <i v-else-if="appearNote.reactionAcceptance === 'likeOnly'" class="ph-heart ph-bold ph-lg"></i> <i v-else class="ph-smiley ph-bold ph-lg"></i> - </button> - <button v-if="appearNote.myReaction != null" ref="reactButton" class="_button" :class="[$style.noteFooterButton, $style.reacted]" @click="undoReact(appearNote)"> - <i class="ph-minus ph-bold ph-lg"></i> + <p v-if="(appearNote.reactionAcceptance === 'likeOnly' || defaultStore.state.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.reactionCount) }}</p> </button> <button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown="clip()"> <i class="ph-paperclip ph-bold ph-lg"></i> @@ -235,6 +237,7 @@ import * as Misskey from 'misskey-js'; import SkNoteSub from '@/components/SkNoteSub.vue'; import SkNoteSimple from '@/components/SkNoteSimple.vue'; import MkReactionsViewer from '@/components/MkReactionsViewer.vue'; +import MkReactionsViewerDetails from '@/components/MkReactionsViewer.details.vue'; import MkMediaList from '@/components/MkMediaList.vue'; import MkCwButton from '@/components/MkCwButton.vue'; import MkPoll from '@/components/MkPoll.vue'; @@ -244,9 +247,10 @@ import SkInstanceTicker from '@/components/SkInstanceTicker.vue'; import { pleaseLogin } from '@/scripts/please-login.js'; import { checkWordMute } from '@/scripts/check-word-mute.js'; import { userPage } from '@/filters/user.js'; +import number from '@/filters/number.js'; import { notePage } from '@/filters/note.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js'; import * as sound from '@/scripts/sound.js'; import { defaultStore, noteViewInterruptors } from '@/store.js'; import { reactionPicker } from '@/scripts/reaction-picker.js'; @@ -267,11 +271,15 @@ import MkPagination, { type Paging } from '@/components/MkPagination.vue'; import MkReactionIcon from '@/components/MkReactionIcon.vue'; import MkButton from '@/components/MkButton.vue'; import { boostMenuItems, type Visibility } from '@/scripts/boost-quote.js'; +import { isEnabledUrlPreview } from '@/instance.js'; -const props = defineProps<{ +const props = withDefaults(defineProps<{ note: Misskey.entities.Note; expandAllCws?: boolean; -}>(); + initialTab: string; +}>(), { + initialTab: 'replies', +}); const inChannel = inject('inChannel', null); @@ -298,7 +306,9 @@ if (noteViewInterruptors.length > 0) { const isRenote = ( note.value.renote != null && + note.value.reply == null && note.value.text == null && + note.value.cw == null && note.value.fileIds && note.value.fileIds.length === 0 && note.value.poll == null ); @@ -364,7 +374,7 @@ provide('react', (reaction: string) => { }); }); -const tab = ref('replies'); +const tab = ref(props.initialTab); const reactionTabType = ref<string | null>(null); const renotesPagination = computed<Paging>(() => ({ @@ -452,6 +462,28 @@ function boostVisibility() { } } +if (appearNote.value.reactionAcceptance === 'likeOnly') { + useTooltip(reactButton, async (showing) => { + const reactions = await misskeyApiGet('notes/reactions', { + noteId: appearNote.value.id, + limit: 10, + _cacheKey_: appearNote.value.reactionCount, + }); + + const users = reactions.map(x => x.user); + + if (users.length < 1) return; + + os.popup(MkReactionsViewerDetails, { + showing, + reaction: '❤️', + users, + count: appearNote.value.reactionCount, + targetElement: reactButton.value!, + }, {}, 'closed'); + }); +} + function renote(visibility: Visibility, localOnly: boolean = false) { pleaseLogin(); showMovedDialog(); @@ -610,11 +642,11 @@ function like(): void { } } -function undoReact(note): void { - const oldReaction = note.myReaction; +function undoReact(targetNote: Misskey.entities.Note): void { + const oldReaction = targetNote.myReaction; if (!oldReaction) return; misskeyApi('notes/reactions/delete', { - noteId: note.id, + noteId: targetNote.id, }); } @@ -635,6 +667,14 @@ function undoRenote() : void { } } +function toggleReact() { + if (appearNote.value.myReaction == null) { + react(); + } else { + undoReact(appearNote.value); + } +} + function onContextmenu(ev: MouseEvent): void { const isLink = (el: HTMLElement): boolean => { if (el.tagName === 'A') return true; diff --git a/packages/frontend/src/components/SkNoteHeader.vue b/packages/frontend/src/components/SkNoteHeader.vue index 7dc4c8f019..2f177815ee 100644 --- a/packages/frontend/src/components/SkNoteHeader.vue +++ b/packages/frontend/src/components/SkNoteHeader.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: marie and other Sharkey contributors SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/SkNoteSub.vue b/packages/frontend/src/components/SkNoteSub.vue index bd17665c2e..dc1d5b10b2 100644 --- a/packages/frontend/src/components/SkNoteSub.vue +++ b/packages/frontend/src/components/SkNoteSub.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: marie and other Sharkey contributors SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/SkOldNoteWindow.vue b/packages/frontend/src/components/SkOldNoteWindow.vue index d2daf3f944..3810b62366 100644 --- a/packages/frontend/src/components/SkOldNoteWindow.vue +++ b/packages/frontend/src/components/SkOldNoteWindow.vue @@ -1,3 +1,8 @@ +<!-- +SPDX-FileCopyrightText: marie and other Sharkey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + <template> <MkWindow ref="window" :initialWidth="500" :initialHeight="300" :canResize="true" @closed="emit('closed')"> <template #header> diff --git a/packages/frontend/src/components/SkOneko.vue b/packages/frontend/src/components/SkOneko.vue index a82258e97e..24bb392335 100644 --- a/packages/frontend/src/components/SkOneko.vue +++ b/packages/frontend/src/components/SkOneko.vue @@ -1,3 +1,8 @@ +<!-- +SPDX-FileCopyrightText: kopper and other Sharkey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + <template> <div ref="nekoEl" :class="$style.oneko" aria-hidden="true"></div> </template> diff --git a/packages/frontend/src/components/SkSearchResultWindow.vue b/packages/frontend/src/components/SkSearchResultWindow.vue index 5a0412685a..474b0d54e7 100644 --- a/packages/frontend/src/components/SkSearchResultWindow.vue +++ b/packages/frontend/src/components/SkSearchResultWindow.vue @@ -1,3 +1,8 @@ +<!-- +SPDX-FileCopyrightText: marie and other Sharkey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + <template> <MkWindow ref="window" :initialWidth="600" :initialHeight="450" :canResize="true" @closed="emit('closed')"> <template #header> diff --git a/packages/frontend/src/components/global/I18n.vue b/packages/frontend/src/components/global/I18n.vue index 162aa2bcf8..6b7723e6ac 100644 --- a/packages/frontend/src/components/global/I18n.vue +++ b/packages/frontend/src/components/global/I18n.vue @@ -1,3 +1,8 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + <template> <render/> </template> diff --git a/packages/frontend/src/components/global/MkA.vue b/packages/frontend/src/components/global/MkA.vue index b3c58cf235..5b67c3f7bd 100644 --- a/packages/frontend/src/components/global/MkA.vue +++ b/packages/frontend/src/components/global/MkA.vue @@ -4,13 +4,17 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<a :href="to" :class="active ? activeClass : null" @click.prevent="nav" @contextmenu.prevent.stop="onContextmenu" @click.stop> +<a ref="el" :href="to" :class="active ? activeClass : null" @click.prevent="nav" @contextmenu.prevent.stop="onContextmenu" @click.stop> <slot></slot> </a> </template> +<script lang="ts"> +export type MkABehavior = 'window' | 'browser' | null; +</script> + <script lang="ts" setup> -import { computed } from 'vue'; +import { computed, inject, shallowRef } from 'vue'; import * as os from '@/os.js'; import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import { url } from '@/config.js'; @@ -20,12 +24,18 @@ import { useRouter } from '@/router/supplier.js'; const props = withDefaults(defineProps<{ to: string; activeClass?: null | string; - behavior?: null | 'window' | 'browser'; + behavior?: MkABehavior; }>(), { activeClass: null, behavior: null, }); +const behavior = props.behavior ?? inject<MkABehavior>('linkNavigationBehavior', null); + +const el = shallowRef<HTMLElement>(); + +defineExpose({ $el: el }); + const router = useRouter(); const active = computed(() => { @@ -76,15 +86,13 @@ function openWindow() { } function nav(ev: MouseEvent) { - if (props.behavior === 'browser') { + if (behavior === 'browser') { location.href = props.to; return; } - if (props.behavior) { - if (props.behavior === 'window') { - return openWindow(); - } + if (behavior === 'window') { + return openWindow(); } if (ev.shiftKey) { diff --git a/packages/frontend/src/components/global/MkAd.stories.impl.ts b/packages/frontend/src/components/global/MkAd.stories.impl.ts index f6cdc2bf23..aef26ab92d 100644 --- a/packages/frontend/src/components/global/MkAd.stories.impl.ts +++ b/packages/frontend/src/components/global/MkAd.stories.impl.ts @@ -4,11 +4,17 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { expect, userEvent, waitFor, within } from '@storybook/test'; import { StoryObj } from '@storybook/vue3'; import MkAd from './MkAd.vue'; +import { i18n } from '@/i18n.js'; let lock: Promise<undefined> | undefined; +function sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + const common = { render(args) { return { @@ -30,7 +36,6 @@ const common = { template: '<MkAd v-bind="props" />', }; }, - /* FIXME: disabled because it still didn’t pass after applying #11267 async play({ canvasElement, args }) { if (lock) { console.warn('This test is unexpectedly running twice in parallel, fix it!'); @@ -42,9 +47,11 @@ const common = { lock = new Promise(r => resolve = r); try { + // NOTE: sleep しないと何故か落ちる + await sleep(100); const canvas = within(canvasElement); const a = canvas.getByRole<HTMLAnchorElement>('link'); - await expect(a.href).toMatch(/^https?:\/\/.*#test$/); + // await expect(a.href).toMatch(/^https?:\/\/.*#test$/); const img = within(a).getByRole('img'); await expect(img).toBeInTheDocument(); let buttons = canvas.getAllByRole<HTMLButtonElement>('button'); @@ -52,13 +59,14 @@ const common = { const i = buttons[0]; await expect(i).toBeInTheDocument(); await userEvent.click(i); - await waitFor(() => expect(canvasElement).toHaveTextContent(i18n.ts._ad.back)); + await expect(canvasElement).toHaveTextContent(i18n.ts._ad.back); await expect(a).not.toBeInTheDocument(); await expect(i).not.toBeInTheDocument(); buttons = canvas.getAllByRole<HTMLButtonElement>('button'); - await expect(buttons).toHaveLength(args.__hasReduce ? 2 : 1); - const reduce = args.__hasReduce ? buttons[0] : null; - const back = buttons[args.__hasReduce ? 1 : 0]; + const hasReduceFrequency = args.specify?.ratio !== 0; + await expect(buttons).toHaveLength(hasReduceFrequency ? 2 : 1); + const reduce = hasReduceFrequency ? buttons[0] : null; + const back = buttons[hasReduceFrequency ? 1 : 0]; if (reduce) { await expect(reduce).toBeInTheDocument(); await expect(reduce).toHaveTextContent(i18n.ts._ad.reduceFrequencyOfThisAd); @@ -80,15 +88,16 @@ const common = { lock = undefined; } }, - */ args: { prefer: [], specify: { id: 'someadid', - radio: 1, + ratio: 1, url: '#test', + place: '', + imageUrl: '', + dayOfWeek: 7, }, - __hasReduce: true, }, parameters: { layout: 'centered', @@ -138,6 +147,5 @@ export const ZeroRatio = { ...Square.args.specify, ratio: 0, }, - __hasReduce: false, }, } satisfies StoryObj<typeof MkAd>; diff --git a/packages/frontend/src/components/global/MkAd.vue b/packages/frontend/src/components/global/MkAd.vue index f13a161ae8..c01211443d 100644 --- a/packages/frontend/src/components/global/MkAd.vue +++ b/packages/frontend/src/components/global/MkAd.vue @@ -14,10 +14,20 @@ SPDX-License-Identifier: AGPL-3.0-only [$style.form_vertical]: chosen.place === 'vertical', }]" > - <a :href="chosen.url" target="_blank" :class="$style.link"> + <component + :is="self ? 'MkA' : 'a'" + :class="$style.link" + v-bind="self ? { + to: chosen.url.substring(local.length), + } : { + href: chosen.url, + rel: 'nofollow noopener', + target: '_blank', + }" + > <img :src="chosen.imageUrl" :class="$style.img"> <button class="_button" :class="$style.i" @click.prevent.stop="toggleMenu"><i :class="$style.iIcon" class="ph-info ph-bold ph-lg"></i></button> - </a> + </component> </div> <div v-else :class="$style.menu"> <div :class="$style.menuContainer"> @@ -32,10 +42,10 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref } from 'vue'; +import { ref, computed } from 'vue'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; -import { host } from '@/config.js'; +import { url as local, host } from '@/config.js'; import MkButton from '@/components/MkButton.vue'; import { defaultStore } from '@/store.js'; import * as os from '@/os.js'; @@ -96,6 +106,9 @@ const choseAd = (): Ad | null => { }; const chosen = ref(choseAd()); + +const self = computed(() => chosen.value?.url.startsWith(local)); + const shouldHide = ref(!defaultStore.state.forceShowAds && $i && $i.policies.canHideAds && (props.specify == null)); function reduceFrequency(): void { diff --git a/packages/frontend/src/components/global/MkAvatar.stories.impl.ts b/packages/frontend/src/components/global/MkAvatar.stories.impl.ts index 933754ec4c..9d2de9f0be 100644 --- a/packages/frontend/src/components/global/MkAvatar.stories.impl.ts +++ b/packages/frontend/src/components/global/MkAvatar.stories.impl.ts @@ -33,7 +33,7 @@ const common = { }, decorators: [ (Story, context) => ({ - // eslint-disable-next-line quotes + // @ts-expect-error size is for test template: `<div :style="{ display: 'grid', width: '${context.args.size}px', height: '${context.args.size}px' }"><story/></div>`, }), ], @@ -45,6 +45,7 @@ export const ProfilePage = { ...common, args: { ...common.args, + // @ts-expect-error size is for test size: 120, indicator: true, }, diff --git a/packages/frontend/src/components/global/MkCondensedLine.stories.impl.ts b/packages/frontend/src/components/global/MkCondensedLine.stories.impl.ts index e4e90cddd5..e15dcba760 100644 --- a/packages/frontend/src/components/global/MkCondensedLine.stories.impl.ts +++ b/packages/frontend/src/components/global/MkCondensedLine.stories.impl.ts @@ -28,6 +28,7 @@ export const Default = { }; }, args: { + // @ts-expect-error text is for test text: 'This is a condensed line.', }, parameters: { @@ -41,4 +42,5 @@ export const ContainerIs100px = { template: '<div style="width: 100px;"><story/></div>', }), ], + // @ts-expect-error text is for test } satisfies StoryObj<typeof MkCondensedLine>; diff --git a/packages/frontend/src/components/global/MkError.stories.meta.ts b/packages/frontend/src/components/global/MkError.stories.meta.ts index 1abbc56f50..cd7fada189 100644 --- a/packages/frontend/src/components/global/MkError.stories.meta.ts +++ b/packages/frontend/src/components/global/MkError.stories.meta.ts @@ -3,8 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { Meta } from '@storybook/vue3'; +import MkError from './MkError.vue'; + export const argTypes = { - retry: { + onRetry: { action: 'retry', }, -}; +} satisfies Meta<typeof MkError>['argTypes']; diff --git a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts index d9d5a4b460..1bc33585f2 100644 --- a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts +++ b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { VNode, h, defineAsyncComponent, SetupContext } from 'vue'; +import { VNode, h, defineAsyncComponent, SetupContext, provide } from 'vue'; import * as mfm from '@transfem-org/sfm-js'; import * as Misskey from 'misskey-js'; import CkFollowMouse from '../CkFollowMouse.vue'; @@ -17,7 +17,7 @@ import MkCode from '@/components/MkCode.vue'; import MkCodeInline from '@/components/MkCodeInline.vue'; import MkGoogle from '@/components/MkGoogle.vue'; import MkSparkle from '@/components/MkSparkle.vue'; -import MkA from '@/components/global/MkA.vue'; +import MkA, { MkABehavior } from '@/components/global/MkA.vue'; import { host } from '@/config.js'; import { defaultStore } from '@/store.js'; import { nyaize as doNyaize } from '@/scripts/nyaize.js'; @@ -45,6 +45,7 @@ type MfmProps = { enableEmojiMenu?: boolean; enableEmojiMenuReaction?: boolean; isAnim?: boolean; + linkNavigationBehavior?: MkABehavior; isBlock?: boolean; }; @@ -54,6 +55,8 @@ type MfmEvents = { // eslint-disable-next-line import/no-default-export export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEvents>['emit'] }) { + provide('linkNavigationBehavior', props.linkNavigationBehavior); + const isNote = props.isNote ?? true; const shouldNyaize = props.nyaize ? props.nyaize === 'respect' ? props.author?.isCat ? props.author.speakAsCat : false : false : false; @@ -232,8 +235,7 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven return h(MkSparkle, {}, genEl(token.children, scale)); } case 'fade': { - // Dont run with reduced motion on - if (!defaultStore.state.animation) { + if (!useAnim) { style = ''; break; } @@ -449,11 +451,11 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven if (!props.nowrap) { return [h('bdi', { class: 'block' }, h('div', { style: QUOTE_STYLE, - }, h('bdi',genEl(token.children, scale, true))))]; + }, h('bdi', genEl(token.children, scale, true))))]; } else { return [h('span', { style: QUOTE_STYLE, - }, h('bdi',genEl(token.children, scale, true)))]; + }, h('bdi', genEl(token.children, scale, true)))]; } } diff --git a/packages/frontend/src/components/global/MkPageHeader.stories.impl.ts b/packages/frontend/src/components/global/MkPageHeader.stories.impl.ts index d4327e1463..25f5051648 100644 --- a/packages/frontend/src/components/global/MkPageHeader.stories.impl.ts +++ b/packages/frontend/src/components/global/MkPageHeader.stories.impl.ts @@ -33,7 +33,6 @@ export const Empty = { await waitFor(async () => await wait); }, args: { - static: true, tabs: [], }, parameters: { @@ -71,8 +70,8 @@ export const IconOnly = { ...Icon.args, tabs: [ { - ...Icon.args.tabs[0], - title: undefined, + key: Icon.args.tabs[0].key, + icon: Icon.args.tabs[0].icon, iconOnly: true, }, ], diff --git a/packages/frontend/src/components/global/MkPageHeader.tabs.vue b/packages/frontend/src/components/global/MkPageHeader.tabs.vue index 53bb5472dc..7d13fb9279 100644 --- a/packages/frontend/src/components/global/MkPageHeader.tabs.vue +++ b/packages/frontend/src/components/global/MkPageHeader.tabs.vue @@ -38,7 +38,6 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts"> export type Tab = { key: string; - title: string; onClick?: (ev: MouseEvent) => void; } & ( | { diff --git a/packages/frontend/src/components/global/MkTime.stories.impl.ts b/packages/frontend/src/components/global/MkTime.stories.impl.ts index 355c839113..ffd4a849a2 100644 --- a/packages/frontend/src/components/global/MkTime.stories.impl.ts +++ b/packages/frontend/src/components/global/MkTime.stories.impl.ts @@ -60,7 +60,7 @@ export const RelativeFuture = { export const AbsoluteFuture = { ...Empty, async play({ canvasElement, args }) { - await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time)); + await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(typeof args.time === 'string' ? new Date(args.time) : args.time ?? undefined)); }, args: { ...Empty.args, @@ -97,7 +97,7 @@ export const RelativeNow = { export const AbsoluteNow = { ...Empty, async play({ canvasElement, args }) { - await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time)); + await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(typeof args.time === 'string' ? new Date(args.time) : args.time ?? undefined)); }, args: { ...Empty.args, @@ -136,7 +136,7 @@ export const RelativeOneHourAgo = { export const AbsoluteOneHourAgo = { ...Empty, async play({ canvasElement, args }) { - await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time)); + await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(typeof args.time === 'string' ? new Date(args.time) : args.time ?? undefined)); }, args: { ...Empty.args, @@ -175,7 +175,7 @@ export const RelativeOneDayAgo = { export const AbsoluteOneDayAgo = { ...Empty, async play({ canvasElement, args }) { - await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time)); + await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(typeof args.time === 'string' ? new Date(args.time) : args.time ?? undefined)); }, args: { ...Empty.args, @@ -214,7 +214,7 @@ export const RelativeOneWeekAgo = { export const AbsoluteOneWeekAgo = { ...Empty, async play({ canvasElement, args }) { - await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time)); + await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(typeof args.time === 'string' ? new Date(args.time) : args.time ?? undefined)); }, args: { ...Empty.args, @@ -253,7 +253,7 @@ export const RelativeOneMonthAgo = { export const AbsoluteOneMonthAgo = { ...Empty, async play({ canvasElement, args }) { - await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time)); + await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(typeof args.time === 'string' ? new Date(args.time) : args.time ?? undefined)); }, args: { ...Empty.args, @@ -292,7 +292,7 @@ export const RelativeOneYearAgo = { export const AbsoluteOneYearAgo = { ...Empty, async play({ canvasElement, args }) { - await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time)); + await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(typeof args.time === 'string' ? new Date(args.time) : args.time ?? undefined)); }, args: { ...Empty.args, diff --git a/packages/frontend/src/components/global/MkTime.vue b/packages/frontend/src/components/global/MkTime.vue index 67532268d3..23fe99bd9c 100644 --- a/packages/frontend/src/components/global/MkTime.vue +++ b/packages/frontend/src/components/global/MkTime.vue @@ -47,7 +47,7 @@ const invalid = Number.isNaN(_time); const absolute = !invalid ? dateTimeFormat.format(_time) : i18n.ts._ago.invalid; // eslint-disable-next-line vue/no-setup-props-destructure -const now = ref((props.origin ?? new Date()).getTime()); +const now = ref(props.origin?.getTime() ?? Date.now()); const ago = computed(() => (now.value - _time) / 1000/*ms*/); const relative = computed<string>(() => { @@ -77,7 +77,7 @@ let tickId: number; let currentInterval: number; function tick() { - now.value = (new Date()).getTime(); + now.value = Date.now(); const nextInterval = ago.value < 60 ? 10000 : ago.value < 3600 ? 60000 : 180000; if (currentInterval !== nextInterval) { diff --git a/packages/frontend/src/components/global/MkUrl.vue b/packages/frontend/src/components/global/MkUrl.vue index b810840b69..19888ae146 100644 --- a/packages/frontend/src/components/global/MkUrl.vue +++ b/packages/frontend/src/components/global/MkUrl.vue @@ -6,6 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <component :is="self ? 'MkA' : 'a'" ref="el" :class="$style.root" class="_link" :[attr]="self ? props.url.substring(local.length) : props.url" :rel="rel ?? 'nofollow noopener'" :target="target" + :behavior="props.navigationBehavior" @contextmenu.stop="() => {}" @click.stop > @@ -31,11 +32,14 @@ import { url as local } from '@/config.js'; import * as os from '@/os.js'; import { useTooltip } from '@/scripts/use-tooltip.js'; import { safeURIDecode } from '@/scripts/safe-uri-decode.js'; +import { isEnabledUrlPreview } from '@/instance.js'; +import { MkABehavior } from '@/components/global/MkA.vue'; const props = withDefaults(defineProps<{ url: string; rel?: string; showUrlPreview?: boolean; + navigationBehavior?: MkABehavior; }>(), { showUrlPreview: true, }); @@ -45,12 +49,12 @@ const url = new URL(props.url); if (!['http:', 'https:'].includes(url.protocol)) throw new Error('invalid url'); const el = ref(); -if (props.showUrlPreview) { +if (props.showUrlPreview && isEnabledUrlPreview.value) { useTooltip(el, (showing) => { os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), { showing, url: props.url, - source: el.value, + source: el.value instanceof HTMLElement ? el.value : el.value?.$el, }, {}, 'closed'); }); } diff --git a/packages/frontend/src/components/global/MkUserName.stories.impl.ts b/packages/frontend/src/components/global/MkUserName.stories.impl.ts index 88bf4f4e6c..e39061c291 100644 --- a/packages/frontend/src/components/global/MkUserName.stories.impl.ts +++ b/packages/frontend/src/components/global/MkUserName.stories.impl.ts @@ -30,7 +30,7 @@ export const Default = { }; }, async play({ canvasElement }) { - await expect(canvasElement).toHaveTextContent(userDetailed().name); + await expect(canvasElement).toHaveTextContent(userDetailed().name as string); }, args: { user: userDetailed(), diff --git a/packages/frontend/src/components/page/page.block.vue b/packages/frontend/src/components/page/page.block.vue index 164720ac6b..c7f72dce8c 100644 --- a/packages/frontend/src/components/page/page.block.vue +++ b/packages/frontend/src/components/page/page.block.vue @@ -14,6 +14,7 @@ import XText from './page.text.vue'; import XSection from './page.section.vue'; import XImage from './page.image.vue'; import XNote from './page.note.vue'; +import XDynamic from './page.dynamic.vue'; function getComponent(type: string) { switch (type) { @@ -21,6 +22,20 @@ function getComponent(type: string) { case 'section': return XSection; case 'image': return XImage; case 'note': return XNote; + + // 動的ページの代替用ブロック + case 'button': + case 'if': + case 'textarea': + case 'post': + case 'canvas': + case 'numberInput': + case 'textInput': + case 'switch': + case 'radioButton': + case 'counter': + return XDynamic; + default: return null; } } diff --git a/packages/frontend/src/components/page/page.dynamic.vue b/packages/frontend/src/components/page/page.dynamic.vue new file mode 100644 index 0000000000..7f80f0c455 --- /dev/null +++ b/packages/frontend/src/components/page/page.dynamic.vue @@ -0,0 +1,43 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<!-- 動的ページのブロックの代替。利用できないということを表示する --> +<template> +<div :class="$style.root"> + <div :class="$style.heading"><i class="ph ph-dice-5 ph-bold ph-lg"></i> {{ i18n.ts._pages.blocks.dynamic }}</div> + <I18n :src="i18n.ts._pages.blocks.dynamicDescription" tag="div" :class="$style.text"> + <template #play> + <MkA to="/play" class="_link">Play</MkA> + </template> + </I18n> +</div> +</template> + +<script lang="ts" setup> +import * as Misskey from 'misskey-js'; +import { i18n } from '@/i18n.js'; + +const props = defineProps<{ + block: Misskey.entities.PageBlock, + page: Misskey.entities.Page, +}>(); +</script> + +<style lang="scss" module> +.root { + border: 1px solid var(--divider); + border-radius: var(--radius); + padding: var(--margin); + text-align: center; +} + +.heading { + font-weight: 700; +} + +.text { + font-size: 90%; +} +</style> diff --git a/packages/frontend/src/components/page/page.image.vue b/packages/frontend/src/components/page/page.image.vue index ced02943db..fc1ce9fc7b 100644 --- a/packages/frontend/src/components/page/page.image.vue +++ b/packages/frontend/src/components/page/page.image.vue @@ -4,19 +4,15 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div> - <MediaImage - v-if="image" - :image="image" - :disableImageLink="true" - /> +<div :class="$style.root"> + <MkMediaList v-if="image" :mediaList="[image]" :class="$style.mediaList"/> </div> </template> <script lang="ts" setup> import { onMounted, ref } from 'vue'; import * as Misskey from 'misskey-js'; -import MediaImage from '@/components/MkMediaImage.vue'; +import MkMediaList from '@/components/MkMediaList.vue'; const props = defineProps<{ block: Misskey.entities.PageBlock, @@ -28,5 +24,17 @@ const image = ref<Misskey.entities.DriveFile | null>(null); onMounted(() => { image.value = props.page.attachedFiles.find(x => x.id === props.block.fileId) ?? null; }); - </script> + +<style lang="scss" module> +.root { + border: 1px solid var(--divider); + border-radius: var(--radius); + overflow: hidden; +} +.mediaList { + // MkMediaList 内の上部マージン 4px + margin-top: -4px; + height: calc(100% + 4px); +} +</style> diff --git a/packages/frontend/src/components/page/page.note.vue b/packages/frontend/src/components/page/page.note.vue index 7b56494a6e..b5ba407806 100644 --- a/packages/frontend/src/components/page/page.note.vue +++ b/packages/frontend/src/components/page/page.note.vue @@ -4,9 +4,9 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div style="margin: 1em 0;"> - <MkNote v-if="note && !block.detailed" :key="note.id + ':normal'" v-model:note="note"/> - <MkNoteDetailed v-if="note && block.detailed" :key="note.id + ':detail'" v-model:note="note"/> +<div :class="$style.root"> + <MkNote v-if="note && !block.detailed" :key="note.id + ':normal'" :note="note"/> + <MkNoteDetailed v-if="note && block.detailed" :key="note.id + ':detail'" :note="note"/> </div> </template> @@ -32,3 +32,10 @@ onMounted(() => { }); }); </script> + +<style lang="scss" module> +.root { + border: 1px solid var(--divider); + border-radius: var(--radius); +} +</style> diff --git a/packages/frontend/src/components/page/page.text.vue b/packages/frontend/src/components/page/page.text.vue index 645290babc..5f88acb11d 100644 --- a/packages/frontend/src/components/page/page.text.vue +++ b/packages/frontend/src/components/page/page.text.vue @@ -4,9 +4,11 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div class="_gaps"> +<div class="_gaps" :class="$style.textRoot"> <Mfm :text="block.text ?? ''" :isBlock="true" :isNote="false"/> - <MkUrlPreview v-for="url in urls" :key="url" :url="url"/> + <div v-if="isEnabledUrlPreview" class="_gaps_s"> + <MkUrlPreview v-for="url in urls" :key="url" :url="url"/> + </div> </div> </template> @@ -15,6 +17,7 @@ import { defineAsyncComponent } from 'vue'; import * as mfm from '@transfem-org/sfm-js'; import * as Misskey from 'misskey-js'; import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js'; +import { isEnabledUrlPreview } from '@/instance.js'; const MkUrlPreview = defineAsyncComponent(() => import('@/components/MkUrlPreview.vue')); @@ -25,3 +28,9 @@ const props = defineProps<{ const urls = props.block.text ? extractUrlFromMfm(mfm.parse(props.block.text)) : []; </script> + +<style lang="scss" module> +.textRoot { + font-size: 1.1rem; +} +</style> diff --git a/packages/frontend/src/components/page/page.vue b/packages/frontend/src/components/page/page.vue index 53c70b01f4..a31c5eff28 100644 --- a/packages/frontend/src/components/page/page.vue +++ b/packages/frontend/src/components/page/page.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="{ [$style.center]: page.alignCenter, [$style.serif]: page.font === 'serif' }" class="_gaps_s"> +<div :class="{ [$style.center]: page.alignCenter, [$style.serif]: page.font === 'serif' }" class="_gaps"> <XBlock v-for="child in page.content" :key="child.id" :page="page" :block="child" :h="2"/> </div> </template> diff --git a/packages/frontend/src/filters/kmg.ts b/packages/frontend/src/filters/kmg.ts index 4dcb5c5800..9608e420f6 100644 --- a/packages/frontend/src/filters/kmg.ts +++ b/packages/frontend/src/filters/kmg.ts @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + export default (v, fractionDigits = 0) => { if (v == null) return 'N/A'; if (v === 0) return '0'; diff --git a/packages/frontend/src/index.html b/packages/frontend/src/index.html index 40e3cdf2ab..733116b75f 100644 --- a/packages/frontend/src/index.html +++ b/packages/frontend/src/index.html @@ -18,7 +18,7 @@ http-equiv="Content-Security-Policy" content="default-src 'self' https://newassets.hcaptcha.com/ https://challenges.cloudflare.com/ http://localhost:7493/; worker-src 'self'; - script-src 'self' 'unsafe-eval' https://*.hcaptcha.com https://challenges.cloudflare.com; + script-src 'self' 'unsafe-eval' https://*.hcaptcha.com https://challenges.cloudflare.com https://esm.sh; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: www.google.com xn--931a.moe launcher.moe localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000 activitypub.software secure.gravatar.com avatars.githubusercontent.com; media-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000; diff --git a/packages/frontend/src/instance.ts b/packages/frontend/src/instance.ts index 4232cbcd78..6847321d6c 100644 --- a/packages/frontend/src/instance.ts +++ b/packages/frontend/src/instance.ts @@ -28,7 +28,7 @@ if (providedAt > cachedAt) { // TODO: instanceをリアクティブにするかは再考の余地あり -export const instance: Misskey.entities.MetaResponse = reactive(cachedMeta ?? {}); +export const instance: Misskey.entities.MetaDetailed = reactive(cachedMeta ?? {}); export const serverErrorImageUrl = computed(() => instance.serverErrorImageUrl ?? DEFAULT_SERVER_ERROR_IMAGE_URL); @@ -36,17 +36,19 @@ export const infoImageUrl = computed(() => instance.infoImageUrl ?? DEFAULT_INFO export const notFoundImageUrl = computed(() => instance.notFoundImageUrl ?? DEFAULT_NOT_FOUND_IMAGE_URL); -export async function fetchInstance(force = false): Promise<void> { +export const isEnabledUrlPreview = computed(() => instance.enableUrlPreview ?? true); + +export async function fetchInstance(force = false): Promise<Misskey.entities.MetaDetailed> { if (!force) { const cachedAt = miLocalStorage.getItem('instanceCachedAt') ? parseInt(miLocalStorage.getItem('instanceCachedAt')!) : 0; if (Date.now() - cachedAt < 1000 * 60 * 60) { - return; + return instance; } } const meta = await misskeyApi('meta', { - detail: false, + detail: true, }); for (const [k, v] of Object.entries(meta)) { @@ -55,4 +57,6 @@ export async function fetchInstance(force = false): Promise<void> { miLocalStorage.setItem('instance', JSON.stringify(instance)); miLocalStorage.setItem('instanceCachedAt', Date.now().toString()); + + return instance; } diff --git a/packages/frontend/src/nirax.ts b/packages/frontend/src/nirax.ts index 616fb104e6..6a8ea09ed6 100644 --- a/packages/frontend/src/nirax.ts +++ b/packages/frontend/src/nirax.ts @@ -373,7 +373,7 @@ export class Router extends EventEmitter<RouterEvent> implements IRouter { this.currentRoute.value = res.route; this.currentKey = res.route.globalCacheKey ?? key ?? path; - if (emitChange) { + if (emitChange && res.route.path !== '/:(*)') { this.emit('change', { beforePath, path, @@ -408,13 +408,17 @@ export class Router extends EventEmitter<RouterEvent> implements IRouter { if (cancel) return; } const res = this.navigate(path, null); - this.emit('push', { - beforePath, - path: res._parsedRoute.fullPath, - route: res.route, - props: res.props, - key: this.currentKey, - }); + if (res.route.path === '/:(*)') { + location.href = path; + } else { + this.emit('push', { + beforePath, + path: res._parsedRoute.fullPath, + route: res.route, + props: res.props, + key: this.currentKey, + }); + } } public replace(path: string, key?: string | null) { diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index fd24039d95..6adf2e590b 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -526,7 +526,7 @@ export function waiting(): Promise<void> { }); } -export function form<F extends Form>(title: string, f: F): Promise<{ canceled: true } | { result: GetFormResultType<F> }> { +export function form<F extends Form>(title: string, f: F): Promise<{ canceled: true, result?: undefined } | { canceled?: false, result: GetFormResultType<F> }> { return new Promise(resolve => { popup(defineAsyncComponent(() => import('@/components/MkFormDialog.vue')), { title, form: f }, { done: result => { diff --git a/packages/frontend/src/pages/admin-user.vue b/packages/frontend/src/pages/admin-user.vue index f99145ae6a..07df36bd11 100644 --- a/packages/frontend/src/pages/admin-user.vue +++ b/packages/frontend/src/pages/admin-user.vue @@ -434,7 +434,7 @@ async function assignRole() { if (canceled) return; const { canceled: canceled2, result: period } = await os.select({ - title: i18n.ts.period, + title: i18n.ts.period + ': ' + roles.find(r => r.id === roleId)!.name, items: [{ value: 'indefinitely', text: i18n.ts.indefinitely, }, { diff --git a/packages/frontend/src/pages/admin/RolesEditorFormula.vue b/packages/frontend/src/pages/admin/RolesEditorFormula.vue index 1dbcc867a1..de1f6646bb 100644 --- a/packages/frontend/src/pages/admin/RolesEditorFormula.vue +++ b/packages/frontend/src/pages/admin/RolesEditorFormula.vue @@ -9,6 +9,11 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSelect v-model="type" :class="$style.typeSelect"> <option value="isLocal">{{ i18n.ts._role._condition.isLocal }}</option> <option value="isRemote">{{ i18n.ts._role._condition.isRemote }}</option> + <option value="isSuspended">{{ i18n.ts._role._condition.isSuspended }}</option> + <option value="isLocked">{{ i18n.ts._role._condition.isLocked }}</option> + <option value="isBot">{{ i18n.ts._role._condition.isBot }}</option> + <option value="isCat">{{ i18n.ts._role._condition.isCat }}</option> + <option value="isExplorable">{{ i18n.ts._role._condition.isExplorable }}</option> <option value="roleAssignedTo">{{ i18n.ts._role._condition.roleAssignedTo }}</option> <option value="createdLessThan">{{ i18n.ts._role._condition.createdLessThan }}</option> <option value="createdMoreThan">{{ i18n.ts._role._condition.createdMoreThan }}</option> diff --git a/packages/frontend/src/pages/admin/approvals.vue b/packages/frontend/src/pages/admin/approvals.vue index 03420232c8..0497e85da3 100644 --- a/packages/frontend/src/pages/admin/approvals.vue +++ b/packages/frontend/src/pages/admin/approvals.vue @@ -1,3 +1,8 @@ +<!-- +SPDX-FileCopyrightText: marie and other Sharkey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + <template> <div> <MkStickyContainer> diff --git a/packages/frontend/src/pages/admin/federation.vue b/packages/frontend/src/pages/admin/federation.vue index db9211929f..c1f958cece 100644 --- a/packages/frontend/src/pages/admin/federation.vue +++ b/packages/frontend/src/pages/admin/federation.vue @@ -59,6 +59,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> +import * as Misskey from 'misskey-js'; import { computed, ref } from 'vue'; import XHeader from './_header_.vue'; import MkInput from '@/components/MkInput.vue'; @@ -92,8 +93,17 @@ const pagination = { })), }; -function getStatus(instance) { - if (instance.isSuspended) return 'Suspended'; +function getStatus(instance: Misskey.entities.FederationInstance) { + switch (instance.suspensionState) { + case 'manuallySuspended': + return 'Manually Suspended'; + case 'goneSuspended': + return 'Automatically Suspended (Gone)'; + case 'autoSuspendedForNotResponding': + return 'Automatically Suspended (Not Responding)'; + case 'none': + break; + } if (instance.isBlocked) return 'Blocked'; if (instance.isSilenced) return 'Silenced'; if (instance.isNotResponding) return 'Error'; diff --git a/packages/frontend/src/pages/admin/files.vue b/packages/frontend/src/pages/admin/files.vue index 2a70f1c4ec..29b5bc5f88 100644 --- a/packages/frontend/src/pages/admin/files.vue +++ b/packages/frontend/src/pages/admin/files.vue @@ -42,7 +42,7 @@ import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkFileListForAdmin from '@/components/MkFileListForAdmin.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { lookupFile } from '@/scripts/admin-lookup.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; @@ -73,33 +73,10 @@ function clear() { }); } -function show(file) { - os.pageWindow(`/admin/file/${file.id}`); -} - -async function find() { - const { canceled, result: q } = await os.inputText({ - title: i18n.ts.fileIdOrUrl, - minLength: 1, - }); - if (canceled) return; - - misskeyApi('admin/drive/show-file', q.startsWith('http://') || q.startsWith('https://') ? { url: q.trim() } : { fileId: q.trim() }).then(file => { - show(file); - }).catch(err => { - if (err.code === 'NO_SUCH_FILE') { - os.alert({ - type: 'error', - text: i18n.ts.notFound, - }); - } - }); -} - const headerActions = computed(() => [{ text: i18n.ts.lookup, icon: 'ph-magnifying-glass ph-bold ph-lg', - handler: find, + handler: lookupFile, }, { text: i18n.ts.clearCachedFiles, icon: 'ph-trash ph-bold ph-lg', diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue index 0fd073dd0d..fae86246ee 100644 --- a/packages/frontend/src/pages/admin/index.vue +++ b/packages/frontend/src/pages/admin/index.vue @@ -12,11 +12,14 @@ SPDX-License-Identifier: AGPL-3.0-only <img :src="instance.iconUrl || '/favicon.ico'" alt="" class="icon"/> </div> - <MkInfo v-if="thereIsUnresolvedAbuseReport" warn class="info">{{ i18n.ts.thereIsUnresolvedAbuseReportWarning }} <MkA to="/admin/abuses" class="_link">{{ i18n.ts.check }}</MkA></MkInfo> - <MkInfo v-if="noMaintainerInformation" warn class="info">{{ i18n.ts.noMaintainerInformationWarning }} <MkA to="/admin/settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> - <MkInfo v-if="noBotProtection" warn class="info">{{ i18n.ts.noBotProtectionWarning }} <MkA to="/admin/security" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> - <MkInfo v-if="noEmailServer" warn class="info">{{ i18n.ts.noEmailServerWarning }} <MkA to="/admin/email-settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> - <MkInfo v-if="pendingUserApprovals" warn class="info">{{ i18n.ts.pendingUserApprovals }} <MkA to="/admin/approvals" class="_link">{{ i18n.ts.check }}</MkA></MkInfo> + <div class="_gaps_s"> + <MkInfo v-if="thereIsUnresolvedAbuseReport" warn class="info">{{ i18n.ts.thereIsUnresolvedAbuseReportWarning }} <MkA to="/admin/abuses" class="_link">{{ i18n.ts.check }}</MkA></MkInfo> + <MkInfo v-if="noMaintainerInformation" warn class="info">{{ i18n.ts.noMaintainerInformationWarning }} <MkA to="/admin/settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> + <MkInfo v-if="noInquiryUrl" warn>{{ i18n.ts.noInquiryUrlWarning }} <MkA to="/admin/moderation" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> + <MkInfo v-if="noBotProtection" warn class="info">{{ i18n.ts.noBotProtectionWarning }} <MkA to="/admin/security" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> + <MkInfo v-if="noEmailServer" warn class="info">{{ i18n.ts.noEmailServerWarning }} <MkA to="/admin/email-settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> + <MkInfo v-if="pendingUserApprovals" warn class="info">{{ i18n.ts.pendingUserApprovals }} <MkA to="/admin/approvals" class="_link">{{ i18n.ts.check }}</MkA></MkInfo> + </div> <MkSuperMenu :def="menuDef" :grid="narrow"></MkSuperMenu> </div> @@ -34,9 +37,10 @@ import { i18n } from '@/i18n.js'; import MkSuperMenu from '@/components/MkSuperMenu.vue'; import MkInfo from '@/components/MkInfo.vue'; import { instance } from '@/instance.js'; +import { lookup } from '@/scripts/lookup.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; -import { lookupUser, lookupUserByEmail } from '@/scripts/lookup-user.js'; +import { lookupUser, lookupUserByEmail, lookupFile } from '@/scripts/admin-lookup.js'; import { PageMetadata, definePageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js'; import { useRouter } from '@/router/supplier.js'; @@ -61,6 +65,7 @@ const pageProps = ref({}); let noMaintainerInformation = isEmpty(instance.maintainerName) || isEmpty(instance.maintainerEmail); let noBotProtection = !instance.disableRegistration && !instance.enableHcaptcha && !instance.enableRecaptcha && !instance.enableMcaptcha && !instance.enableTurnstile; let noEmailServer = !instance.enableEmail; +let noInquiryUrl = isEmpty(instance.inquiryUrl); const thereIsUnresolvedAbuseReport = ref(false); const pendingUserApprovals = ref(false); const currentPage = computed(() => router.currentRef.value.child); @@ -92,7 +97,7 @@ const menuDef = computed(() => [{ type: 'button', icon: 'ph-magnifying-glass ph-bold ph-lg', text: i18n.ts.lookup, - action: lookup, + action: adminLookup, }, ...(instance.disableRegistration ? [{ type: 'button', icon: 'ph-user-plus ph-bold ph-lg', @@ -297,7 +302,7 @@ function invite() { }); } -function lookup(ev: MouseEvent) { +function adminLookup(ev: MouseEvent) { os.popupMenu([{ text: i18n.ts.user, icon: 'ph-user ph-bold ph-lg', @@ -311,22 +316,16 @@ function lookup(ev: MouseEvent) { lookupUserByEmail(); }, }, { - text: i18n.ts.note, - icon: 'ph-pencil-simple ph-bold ph-lg', - action: () => { - alert('TODO'); - }, - }, { text: i18n.ts.file, icon: 'ph-cloud ph-bold ph-lg', action: () => { - alert('TODO'); + lookupFile(); }, }, { - text: i18n.ts.instance, + text: i18n.ts.lookup, icon: 'ph-planet ph-bold ph-lg', action: () => { - alert('TODO'); + lookup(); }, }], ev.currentTarget ?? ev.target); } @@ -368,10 +367,6 @@ defineExpose({ > .nav { .lxpfedzu { - > .info { - margin: 16px 0; - } - > .banner { margin: 16px; diff --git a/packages/frontend/src/pages/admin/moderation.vue b/packages/frontend/src/pages/admin/moderation.vue index 13af28b659..46ffb6bbc6 100644 --- a/packages/frontend/src/pages/admin/moderation.vue +++ b/packages/frontend/src/pages/admin/moderation.vue @@ -39,6 +39,12 @@ SPDX-License-Identifier: AGPL-3.0-only <template #caption>Choose which instances should be displayed in the bubble.</template> </MkTextarea> + <MkInput v-model="inquiryUrl" type="url"> + <template #prefix><i class="ph-link ph-bold ph-lg"></i></template> + <template #label>{{ i18n.ts._serverSettings.inquiryUrl }}</template> + <template #caption>{{ i18n.ts._serverSettings.inquiryUrlDescription }}</template> + </MkInput> + <MkTextarea v-model="preservedUsernames"> <template #label>{{ i18n.ts.preservedUsernames }}</template> <template #caption>{{ i18n.ts.preservedUsernamesDescription }}</template> @@ -98,6 +104,7 @@ const preservedUsernames = ref<string>(''); const bubbleTimeline = ref<string>(''); const tosUrl = ref<string | null>(null); const privacyPolicyUrl = ref<string | null>(null); +const inquiryUrl = ref<string | null>(null); async function init() { const meta = await misskeyApi('admin/meta'); @@ -112,6 +119,7 @@ async function init() { privacyPolicyUrl.value = meta.privacyPolicyUrl; bubbleTimeline.value = meta.bubbleInstances.join('\n'); bubbleTimelineEnabled.value = meta.policies.btlAvailable; + inquiryUrl.value = meta.inquiryUrl; } function save() { @@ -121,6 +129,7 @@ function save() { approvalRequiredForSignup: approvalRequiredForSignup.value, tosUrl: tosUrl.value, privacyPolicyUrl: privacyPolicyUrl.value, + inquiryUrl: inquiryUrl.value, sensitiveWords: sensitiveWords.value.split('\n'), prohibitedWords: prohibitedWords.value.split('\n'), hiddenTags: hiddenTags.value.split('\n'), diff --git a/packages/frontend/src/pages/admin/roles.role.vue b/packages/frontend/src/pages/admin/roles.role.vue index c084d5f8df..593183ea07 100644 --- a/packages/frontend/src/pages/admin/roles.role.vue +++ b/packages/frontend/src/pages/admin/roles.role.vue @@ -119,7 +119,7 @@ async function assign() { const user = await os.selectUser({ includeSelf: true }); const { canceled: canceled2, result: period } = await os.select({ - title: i18n.ts.period, + title: i18n.ts.period + ': ' + role.name, items: [{ value: 'indefinitely', text: i18n.ts.indefinitely, }, { diff --git a/packages/frontend/src/pages/admin/security.vue b/packages/frontend/src/pages/admin/security.vue index 8e75975209..887d3c1446 100644 --- a/packages/frontend/src/pages/admin/security.vue +++ b/packages/frontend/src/pages/admin/security.vue @@ -75,19 +75,6 @@ SPDX-License-Identifier: AGPL-3.0-only </MkSwitch> </div> </MkFolder> - - <MkFolder> - <template #label>Summaly Proxy</template> - - <div class="_gaps_m"> - <MkInput v-model="summalyProxy"> - <template #prefix><i class="ph-link ph-bold ph-lg"></i></template> - <template #label>Summaly Proxy URL</template> - </MkInput> - - <MkButton primary @click="save"><i class="ph-floppy-disk ph-bold ph-lg"></i> {{ i18n.ts.save }}</MkButton> - </div> - </MkFolder> </div> </FormSuspense> </MkSpacer> @@ -112,7 +99,6 @@ import { fetchInstance } from '@/instance.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; -const summalyProxy = ref<string>(''); const enableHcaptcha = ref<boolean>(false); const enableMcaptcha = ref<boolean>(false); const enableRecaptcha = ref<boolean>(false); @@ -128,7 +114,6 @@ const bannedEmailDomains = ref<string>(''); async function init() { const meta = await misskeyApi('admin/meta'); - summalyProxy.value = meta.summalyProxy; enableHcaptcha.value = meta.enableHcaptcha; enableMcaptcha.value = meta.enableMcaptcha; enableRecaptcha.value = meta.enableRecaptcha; @@ -145,7 +130,6 @@ async function init() { function save() { os.apiWithDialog('admin/update-meta', { - summalyProxy: summalyProxy.value, enableIpLogging: enableIpLogging.value, enableActiveEmailValidation: enableActiveEmailValidation.value, enableVerifymailApi: enableVerifymailApi.value, diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue index 887ac6fb4c..ebbe252cb7 100644 --- a/packages/frontend/src/pages/admin/settings.vue +++ b/packages/frontend/src/pages/admin/settings.vue @@ -148,6 +148,53 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </FormSection> + + <FormSection> + <template #label>{{ i18n.ts._urlPreviewSetting.title }}</template> + + <div class="_gaps_m"> + <MkSwitch v-model="urlPreviewEnabled"> + <template #label>{{ i18n.ts._urlPreviewSetting.enable }}</template> + </MkSwitch> + + <MkSwitch v-model="urlPreviewRequireContentLength"> + <template #label>{{ i18n.ts._urlPreviewSetting.requireContentLength }}</template> + <template #caption>{{ i18n.ts._urlPreviewSetting.requireContentLengthDescription }}</template> + </MkSwitch> + + <MkInput v-model="urlPreviewMaximumContentLength" type="number"> + <template #label>{{ i18n.ts._urlPreviewSetting.maximumContentLength }}</template> + <template #caption>{{ i18n.ts._urlPreviewSetting.maximumContentLengthDescription }}</template> + </MkInput> + + <MkInput v-model="urlPreviewTimeout" type="number"> + <template #label>{{ i18n.ts._urlPreviewSetting.timeout }}</template> + <template #caption>{{ i18n.ts._urlPreviewSetting.timeoutDescription }}</template> + </MkInput> + + <MkInput v-model="urlPreviewUserAgent" type="text"> + <template #label>{{ i18n.ts._urlPreviewSetting.userAgent }}</template> + <template #caption>{{ i18n.ts._urlPreviewSetting.userAgentDescription }}</template> + </MkInput> + + <div> + <MkInput v-model="urlPreviewSummaryProxyUrl" type="text"> + <template #label>{{ i18n.ts._urlPreviewSetting.summaryProxy }}</template> + <template #caption>[{{ i18n.ts.notUsePleaseLeaveBlank }}] {{ i18n.ts._urlPreviewSetting.summaryProxyDescription }}</template> + </MkInput> + + <div :class="$style.subCaption"> + {{ i18n.ts._urlPreviewSetting.summaryProxyDescription2 }} + <ul style="padding-left: 20px; margin: 4px 0"> + <li>{{ i18n.ts._urlPreviewSetting.timeout }} / key:timeout</li> + <li>{{ i18n.ts._urlPreviewSetting.maximumContentLength }} / key:contentLengthLimit</li> + <li>{{ i18n.ts._urlPreviewSetting.requireContentLength }} / key:contentLengthRequired</li> + <li>{{ i18n.ts._urlPreviewSetting.userAgent }} / key:userAgent</li> + </ul> + </div> + </div> + </div> + </FormSection> </div> </FormSuspense> </MkSpacer> @@ -178,6 +225,8 @@ import { fetchInstance, instance } from '@/instance.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import MkButton from '@/components/MkButton.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import MkSelect from '@/components/MkSelect.vue'; const name = ref<string | null>(null); const shortName = ref<string | null>(null); @@ -200,6 +249,12 @@ const perRemoteUserUserTimelineCacheMax = ref<number>(0); const perUserHomeTimelineCacheMax = ref<number>(0); const perUserListTimelineCacheMax = ref<number>(0); const notesPerOneAd = ref<number>(0); +const urlPreviewEnabled = ref<boolean>(true); +const urlPreviewTimeout = ref<number>(10000); +const urlPreviewMaximumContentLength = ref<number>(1024 * 1024 * 10); +const urlPreviewRequireContentLength = ref<boolean>(true); +const urlPreviewUserAgent = ref<string | null>(null); +const urlPreviewSummaryProxyUrl = ref<string | null>(null); async function init(): Promise<void> { const meta = await misskeyApi('admin/meta'); @@ -224,9 +279,15 @@ async function init(): Promise<void> { perUserHomeTimelineCacheMax.value = meta.perUserHomeTimelineCacheMax; perUserListTimelineCacheMax.value = meta.perUserListTimelineCacheMax; notesPerOneAd.value = meta.notesPerOneAd; + urlPreviewEnabled.value = meta.urlPreviewEnabled; + urlPreviewTimeout.value = meta.urlPreviewTimeout; + urlPreviewMaximumContentLength.value = meta.urlPreviewMaximumContentLength; + urlPreviewRequireContentLength.value = meta.urlPreviewRequireContentLength; + urlPreviewUserAgent.value = meta.urlPreviewUserAgent; + urlPreviewSummaryProxyUrl.value = meta.urlPreviewSummaryProxyUrl; } -async function save(): void { +async function save() { await os.apiWithDialog('admin/update-meta', { name: name.value, shortName: shortName.value === '' ? null : shortName.value, @@ -249,6 +310,12 @@ async function save(): void { perUserHomeTimelineCacheMax: perUserHomeTimelineCacheMax.value, perUserListTimelineCacheMax: perUserListTimelineCacheMax.value, notesPerOneAd: notesPerOneAd.value, + urlPreviewEnabled: urlPreviewEnabled.value, + urlPreviewTimeout: urlPreviewTimeout.value, + urlPreviewMaximumContentLength: urlPreviewMaximumContentLength.value, + urlPreviewRequireContentLength: urlPreviewRequireContentLength.value, + urlPreviewUserAgent: urlPreviewUserAgent.value, + urlPreviewSummaryProxyUrl: urlPreviewSummaryProxyUrl.value, }); fetchInstance(true); @@ -267,4 +334,9 @@ definePageMetadata(() => ({ -webkit-backdrop-filter: var(--blur, blur(15px)); backdrop-filter: var(--blur, blur(15px)); } + +.subCaption { + font-size: 0.85em; + color: var(--fgTransparentWeak); +} </style> diff --git a/packages/frontend/src/pages/admin/users.vue b/packages/frontend/src/pages/admin/users.vue index 001b7dc82d..80696c8cea 100644 --- a/packages/frontend/src/pages/admin/users.vue +++ b/packages/frontend/src/pages/admin/users.vue @@ -64,7 +64,7 @@ import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkPagination from '@/components/MkPagination.vue'; import * as os from '@/os.js'; -import { lookupUser } from '@/scripts/lookup-user.js'; +import { lookupUser } from '@/scripts/admin-lookup.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; diff --git a/packages/frontend/src/pages/announcement.vue b/packages/frontend/src/pages/announcement.vue new file mode 100644 index 0000000000..fc4ec19b4e --- /dev/null +++ b/packages/frontend/src/pages/announcement.vue @@ -0,0 +1,142 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :contentMax="800"> + <Transition + :enterActiveClass="defaultStore.state.animation ? $style.fadeEnterActive : ''" + :leaveActiveClass="defaultStore.state.animation ? $style.fadeLeaveActive : ''" + :enterFromClass="defaultStore.state.animation ? $style.fadeEnterFrom : ''" + :leaveToClass="defaultStore.state.animation ? $style.fadeLeaveTo : ''" + mode="out-in" + > + <div v-if="announcement" :key="announcement.id" class="_panel" :class="$style.announcement"> + <div v-if="announcement.forYou" :class="$style.forYou"><i class="ph-push-pin ph-bold ph-lg"></i> {{ i18n.ts.forYou }}</div> + <div :class="$style.header"> + <span v-if="$i && !announcement.silence && !announcement.isRead" style="margin-right: 0.5em;">🆕</span> + <span style="margin-right: 0.5em;"> + <i v-if="announcement.icon === 'info'" class="ph-info ph-bold ph-lg"></i> + <i v-else-if="announcement.icon === 'warning'" class="ph-warning-circle ph-bold ph-lg" style="color: var(--warn);"></i> + <i v-else-if="announcement.icon === 'error'" class="ph-seal-warning ph-bold ph-lg" style="color: var(--error);"></i> + <i v-else-if="announcement.icon === 'success'" class="ph-check-circle ph-bold ph-lg" style="color: var(--success);"></i> + </span> + <Mfm :text="announcement.title"/> + </div> + <div :class="$style.content"> + <Mfm :text="announcement.text"/> + <img v-if="announcement.imageUrl" :src="announcement.imageUrl"/> + <div style="margin-top: 8px; opacity: 0.7; font-size: 85%;"> + {{ i18n.ts.createdAt }}: <MkTime :time="announcement.createdAt" mode="detail"/> + </div> + <div v-if="announcement.updatedAt" style="opacity: 0.7; font-size: 85%;"> + {{ i18n.ts.updatedAt }}: <MkTime :time="announcement.updatedAt" mode="detail"/> + </div> + </div> + <div v-if="$i && !announcement.silence && !announcement.isRead" :class="$style.footer"> + <MkButton primary @click="read(announcement)"><i class="ph-check ph-bold ph-lg"></i> {{ i18n.ts.gotIt }}</MkButton> + </div> + </div> + <MkError v-else-if="error" @retry="fetch()"/> + <MkLoading v-else/> + </Transition> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { ref, computed, watch } from 'vue'; +import * as Misskey from 'misskey-js'; +import MkButton from '@/components/MkButton.vue'; +import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; +import { i18n } from '@/i18n.js'; +import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { $i, updateAccount } from '@/account.js'; +import { defaultStore } from '@/store.js'; + +const props = defineProps<{ + announcementId: string; +}>(); + +const announcement = ref<Misskey.entities.Announcement | null>(null); +const error = ref<any>(null); +const path = computed(() => props.announcementId); + +function fetch() { + announcement.value = null; + misskeyApi('announcements/show', { + announcementId: props.announcementId, + }).then(async _announcement => { + announcement.value = _announcement; + }).catch(err => { + error.value = err; + }); +} + +async function read(target: Misskey.entities.Announcement): Promise<void> { + if (target.needConfirmationToRead) { + const confirm = await os.confirm({ + type: 'question', + title: i18n.ts._announcement.readConfirmTitle, + text: i18n.tsx._announcement.readConfirmText({ title: target.title }), + }); + if (confirm.canceled) return; + } + + target.isRead = true; + await misskeyApi('i/read-announcement', { announcementId: target.id }); + if ($i) { + updateAccount({ + unreadAnnouncements: $i.unreadAnnouncements.filter((a: { id: string; }) => a.id !== target.id), + }); + } +} + +watch(() => path.value, fetch, { immediate: true }); + +const headerActions = computed(() => []); + +const headerTabs = computed(() => []); + +definePageMetadata(() => ({ + title: announcement.value ? `${i18n.ts.announcements}: ${announcement.value.title}` : i18n.ts.announcements, + icon: 'ph-megaphone ph-bold ph-lg', +})); +</script> + +<style lang="scss" module> +.announcement { + padding: 16px; +} + +.forYou { + display: flex; + align-items: center; + line-height: 24px; + font-size: 90%; + white-space: pre; + color: #d28a3f; +} + +.header { + margin-bottom: 16px; + font-weight: bold; + font-size: 120%; +} + +.content { + > img { + display: block; + max-height: 300px; + max-width: 100%; + } +} + +.footer { + margin-top: 16px; +} +</style> diff --git a/packages/frontend/src/pages/announcements.vue b/packages/frontend/src/pages/announcements.vue index 2b89e30c21..44dd2e90d8 100644 --- a/packages/frontend/src/pages/announcements.vue +++ b/packages/frontend/src/pages/announcements.vue @@ -21,14 +21,19 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-else-if="announcement.icon === 'error'" class="ph-x-circle ph-bold ph-lg" style="color: var(--error);"></i> <i v-else-if="announcement.icon === 'success'" class="ph-check ph-bold ph-lg" style="color: var(--success);"></i> </span> - <span>{{ announcement.title }}</span> + <MkA :to="`/announcements/${announcement.id}`"><span>{{ announcement.title }}</span></MkA> </div> <div :class="$style.content"> <Mfm :text="announcement.text" :isBlock="true" /> <img v-if="announcement.imageUrl" :src="announcement.imageUrl"/> - <div style="opacity: 0.7; font-size: 85%;"> - <MkTime :time="announcement.updatedAt ?? announcement.createdAt" mode="detail"/> - </div> + <MkA :to="`/announcements/${announcement.id}`"> + <div style="margin-top: 8px; opacity: 0.7; font-size: 85%;"> + {{ i18n.ts.createdAt }}: <MkTime :time="announcement.createdAt" mode="detail"/> + </div> + <div v-if="announcement.updatedAt" style="opacity: 0.7; font-size: 85%;"> + {{ i18n.ts.updatedAt }}: <MkTime :time="announcement.updatedAt" mode="detail"/> + </div> + </MkA> </div> <div v-if="tab !== 'past' && $i && !announcement.silence && !announcement.isRead" :class="$style.footer"> <MkButton primary @click="read(announcement)"><i class="ph-check ph-bold ph-lg"></i> {{ i18n.ts.gotIt }}</MkButton> @@ -73,24 +78,24 @@ const paginationEl = ref<InstanceType<typeof MkPagination>>(); const tab = ref('current'); -async function read(announcement) { - if (announcement.needConfirmationToRead) { +async function read(target) { + if (target.needConfirmationToRead) { const confirm = await os.confirm({ type: 'question', title: i18n.ts._announcement.readConfirmTitle, - text: i18n.tsx._announcement.readConfirmText({ title: announcement.title }), + text: i18n.tsx._announcement.readConfirmText({ title: target.title }), }); if (confirm.canceled) return; } if (!paginationEl.value) return; - paginationEl.value.updateItem(announcement.id, a => { + paginationEl.value.updateItem(target.id, a => { a.isRead = true; return a; }); - misskeyApi('i/read-announcement', { announcementId: announcement.id }); + misskeyApi('i/read-announcement', { announcementId: target.id }); updateAccount({ - unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== announcement.id), + unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== target.id), }); } diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue index 847446eb72..7f49c984a8 100644 --- a/packages/frontend/src/pages/channel.vue +++ b/packages/frontend/src/pages/channel.vue @@ -83,6 +83,7 @@ import { definePageMetadata } from '@/scripts/page-metadata.js'; import { deviceKind } from '@/scripts/device-kind.js'; import MkNotes from '@/components/MkNotes.vue'; import { url } from '@/config.js'; +import { favoritedChannelsCache } from '@/cache.js'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import { defaultStore } from '@/store.js'; @@ -170,6 +171,7 @@ function favorite() { channelId: channel.value.id, }).then(() => { favorited.value = true; + favoritedChannelsCache.delete(); }); } @@ -185,6 +187,7 @@ async function unfavorite() { channelId: channel.value.id, }).then(() => { favorited.value = false; + favoritedChannelsCache.delete(); }); } diff --git a/packages/frontend/src/pages/clip.vue b/packages/frontend/src/pages/clip.vue index 61ffe06fa3..9c42d2d3b8 100644 --- a/packages/frontend/src/pages/clip.vue +++ b/packages/frontend/src/pages/clip.vue @@ -9,11 +9,16 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSpacer :contentMax="800"> <div v-if="clip" class="_gaps"> <div class="_panel"> - <div v-if="clip.description" :class="$style.description"> - <Mfm :text="clip.description" :isBlock="true" :isNote="false"/> + <div class="_gaps_s" :class="$style.description"> + <div v-if="clip.description"> + <Mfm :text="clip.description" :isBlock="true" :isNote="false"/> + </div> + <div v-else>({{ i18n.ts.noDescription }})</div> + <div> + <MkButton v-if="favorited" v-tooltip="i18n.ts.unfavorite" asLike rounded primary @click="unfavorite()"><i class="ph-heart ph-bold pd-lg"></i><span v-if="clip.favoritedCount > 0" style="margin-left: 6px;">{{ clip.favoritedCount }}</span></MkButton> + <MkButton v-else v-tooltip="i18n.ts.favorite" asLike rounded @click="favorite()"><i class="ph-heart ph-bold pd-lg"></i><span v-if="clip.favoritedCount > 0" style="margin-left: 6px;">{{ clip.favoritedCount }}</span></MkButton> + </div> </div> - <MkButton v-if="favorited" v-tooltip="i18n.ts.unfavorite" asLike rounded primary @click="unfavorite()"><i class="ph-heart ph-bold ph-lg"></i><span v-if="clip.favoritedCount > 0" style="margin-left: 6px;">{{ clip.favoritedCount }}</span></MkButton> - <MkButton v-else v-tooltip="i18n.ts.favorite" asLike rounded @click="favorite()"><i class="ph-heart ph-bold ph-lg"></i><span v-if="clip.favoritedCount > 0" style="margin-left: 6px;">{{ clip.favoritedCount }}</span></MkButton> <div :class="$style.user"> <MkAvatar :user="clip.user" :class="$style.avatar" indicator link preview/> <MkUserName :user="clip.user" :nowrap="false"/> </div> diff --git a/packages/frontend/src/pages/contact.vue b/packages/frontend/src/pages/contact.vue new file mode 100644 index 0000000000..b6cfebf229 --- /dev/null +++ b/packages/frontend/src/pages/contact.vue @@ -0,0 +1,40 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkStickyContainer> + <template #header><MkPageHeader/></template> + <MkSpacer :contentMax="600" :marginMin="20"> + <div class="_gaps"> + <MkKeyValue> + <template #key>{{ i18n.ts.inquiry }}</template> + <template #value> + <MkLink :url="instance.inquiryUrl" target="_blank">{{ instance.inquiryUrl }}</MkLink> + </template> + </MkKeyValue> + + <MkKeyValue> + <template #key>{{ i18n.ts.email }}</template> + <template #value> + <div>{{ instance.maintainerEmail }}</div> + </template> + </MkKeyValue> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { i18n } from '@/i18n.js'; +import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { instance } from '@/instance.js'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import MkLink from '@/components/MkLink.vue'; + +definePageMetadata(() => ({ + title: i18n.ts.inquiry, + icon: 'ph-question ph-bold ph-lg', +})); +</script> diff --git a/packages/frontend/src/pages/explore.featured.vue b/packages/frontend/src/pages/explore.featured.vue index b5c8e70166..cfdb235d3a 100644 --- a/packages/frontend/src/pages/explore.featured.vue +++ b/packages/frontend/src/pages/explore.featured.vue @@ -29,6 +29,9 @@ const paginationForPolls = { endpoint: 'notes/polls/recommendation' as const, limit: 10, offsetMode: true, + params: { + excludeChannels: true, + }, }; const tab = ref('notes'); diff --git a/packages/frontend/src/pages/flash/flash-edit.vue b/packages/frontend/src/pages/flash/flash-edit.vue index 53c8c78914..1a832dfdec 100644 --- a/packages/frontend/src/pages/flash/flash-edit.vue +++ b/packages/frontend/src/pages/flash/flash-edit.vue @@ -18,16 +18,17 @@ SPDX-License-Identifier: AGPL-3.0-only <MkCodeEditor v-model="script" lang="is"> <template #label>{{ i18n.ts._play.script }}</template> </MkCodeEditor> - <div class="_buttons"> - <MkButton primary @click="save"><i class="ph-check ph-bold ph-lg"></i> {{ i18n.ts.save }}</MkButton> - <MkButton @click="show"><i class="ph-eye ph-bold ph-lg"></i> {{ i18n.ts.show }}</MkButton> - <MkButton v-if="flash" danger @click="del"><i class="ph-trash ph-bold ph-lg"></i> {{ i18n.ts.delete }}</MkButton> - </div> <MkSelect v-model="visibility"> <template #label>{{ i18n.ts.visibility }}</template> + <template #caption>{{ i18n.ts._play.visibilityDescription }}</template> <option :key="'public'" :value="'public'">{{ i18n.ts.public }}</option> <option :key="'private'" :value="'private'">{{ i18n.ts.private }}</option> </MkSelect> + <div class="_buttons"> + <MkButton primary @click="save"><i class="ph-check ph-bold ph-lg"></i> {{ i18n.ts.save }}</MkButton> + <MkButton @click="show"><i class="ph-eye ph-bold ph-lg"></i> {{ i18n.ts.show }}</MkButton> + <MkButton v-if="flash" danger @click="del"><i class="ph-trash ph-bold ph-lg"></i> {{ i18n.ts.delete }}</MkButton> + </div> </div> </MkSpacer> </MkStickyContainer> @@ -47,7 +48,7 @@ import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; import { useRouter } from '@/router/supplier.js'; -const PRESET_DEFAULT = `/// @ 0.16.0 +const PRESET_DEFAULT = `/// @ 0.18.0 var name = "" @@ -59,13 +60,13 @@ Ui:render([ Ui:C:button({ text: "Hello" onClick: @() { - Mk:dialog(null \`Hello, {name}!\`) + Mk:dialog(null, \`Hello, {name}!\`) } }) ]) `; -const PRESET_OMIKUJI = `/// @ 0.16.0 +const PRESET_OMIKUJI = `/// @ 0.18.0 // ユーザーごとに日替わりのおみくじのプリセット // 選択肢 @@ -80,11 +81,11 @@ let choices = [ "大凶" ] -// シードが「ユーザーID+今日の日付」である乱数生成器を用意 -let random = Math:gen_rng(\`{USER_ID}{Date:year()}{Date:month()}{Date:day()}\`) +// シードが「PlayID+ユーザーID+今日の日付」である乱数生成器を用意 +let random = Math:gen_rng(\`{THIS_ID}{USER_ID}{Date:year()}{Date:month()}{Date:day()}\`) // ランダムに選択肢を選ぶ -let chosen = choices[random(0 (choices.len - 1))] +let chosen = choices[random(0, (choices.len - 1))] // 結果のテキスト let result = \`今日のあなたの運勢は **{chosen}** です。\` @@ -108,7 +109,7 @@ Ui:render([ ]) `; -const PRESET_SHUFFLE = `/// @ 0.16.0 +const PRESET_SHUFFLE = `/// @ 0.18.0 // 巻き戻し可能な文字シャッフルのプリセット let string = "ペペロンチーノ" @@ -122,13 +123,13 @@ var cursor = 0 @do() { if (cursor != 0) { - results = results.slice(0 (cursor + 1)) + results = results.slice(0, (cursor + 1)) cursor = 0 } let chars = [] for (let i, length) { - let r = Math:rnd(0 (length - 1)) + let r = Math:rnd(0, (length - 1)) chars.push(string.pick(r)) } let result = chars.join("") @@ -162,11 +163,11 @@ var cursor = 0 text: "←" disabled: !(results.len > 1 && (results.len - cursor) > 1) onClick: back - } { + }, { text: "→" disabled: !(results.len > 1 && cursor > 0) onClick: forward - } { + }, { text: "引き直す" onClick: do }] @@ -187,27 +188,27 @@ var cursor = 0 do() `; -const PRESET_QUIZ = `/// @ 0.16.0 +const PRESET_QUIZ = `/// @ 0.18.0 let title = '地理クイズ' let qas = [{ q: 'オーストラリアの首都は?' - choices: ['シドニー' 'キャンベラ' 'メルボルン'] + choices: ['シドニー', 'キャンベラ', 'メルボルン'] a: 'キャンベラ' aDescription: '最大の都市はシドニーですが首都はキャンベラです。' -} { +}, { q: '国土面積2番目の国は?' - choices: ['カナダ' 'アメリカ' '中国'] + choices: ['カナダ', 'アメリカ', '中国'] a: 'カナダ' aDescription: '大きい順にロシア、カナダ、アメリカ、中国です。' -} { +}, { q: '二重内陸国ではないのは?' - choices: ['リヒテンシュタイン' 'ウズベキスタン' 'レソト'] + choices: ['リヒテンシュタイン', 'ウズベキスタン', 'レソト'] a: 'レソト' aDescription: 'レソトは(一重)内陸国です。' -} { +}, { q: '閘門がない運河は?' - choices: ['キール運河' 'スエズ運河' 'パナマ運河'] + choices: ['キール運河', 'スエズ運河', 'パナマ運河'] a: 'スエズ運河' aDescription: 'スエズ運河は高低差がないので閘門はありません。' }] @@ -243,9 +244,9 @@ each (let qa, qas) { }) Ui:C:container({ children: [] - } \`{qa.id}:a\`) + }, \`{qa.id}:a\`) ] - } qa.id)) + }, qa.id)) } @finish() { @@ -295,12 +296,12 @@ qaEls.push(Ui:C:container({ onClick: finish }) ] -} 'footer')) +}, 'footer')) Ui:render(qaEls) `; -const PRESET_TIMELINE = `/// @ 0.16.0 +const PRESET_TIMELINE = `/// @ 0.18.0 // APIリクエストを行いローカルタイムラインを表示するプリセット @fetch() { @@ -314,7 +315,7 @@ const PRESET_TIMELINE = `/// @ 0.16.0 ]) // タイムライン取得 - let notes = Mk:api("notes/local-timeline" {}) + let notes = Mk:api("notes/local-timeline", {}) // それぞれのノートごとにUI要素作成 let noteEls = [] @@ -367,7 +368,7 @@ const props = defineProps<{ }>(); const flash = ref<Misskey.entities.Flash | null>(null); -const visibility = ref<Misskey.entities.FlashUpdateRequest['visibility']>('public'); +const visibility = ref<'private' | 'public'>('public'); if (props.id) { flash.value = await misskeyApi('flash/show', { @@ -420,6 +421,7 @@ async function save() { summary: summary.value, permissions: permissions.value, script: script.value, + visibility: visibility.value, }); router.push('/play/' + created.id + '/edit'); } diff --git a/packages/frontend/src/pages/flash/flash.vue b/packages/frontend/src/pages/flash/flash.vue index 71c0990876..febfdbaaf7 100644 --- a/packages/frontend/src/pages/flash/flash.vue +++ b/packages/frontend/src/pages/flash/flash.vue @@ -15,11 +15,15 @@ SPDX-License-Identifier: AGPL-3.0-only <MkAsUi v-if="root" :component="root" :components="components"/> </div> <div class="actions _panel"> - <MkButton v-if="flash.isLiked" v-tooltip="i18n.ts.unlike" asLike class="button" rounded primary @click="unlike()"><i class="ph-heart ph-bold ph-lg"></i><span v-if="flash.likedCount > 0" style="margin-left: 6px;">{{ flash.likedCount }}</span></MkButton> - <MkButton v-else v-tooltip="i18n.ts.like" asLike class="button" rounded @click="like()"><i class="ph-heart ph-bold ph-lg"></i><span v-if="flash.likedCount > 0" style="margin-left: 6px;">{{ flash.likedCount }}</span></MkButton> - <MkButton v-tooltip="i18n.ts.shareWithNote" class="button" rounded @click="shareWithNote"><i class="ph-repeat ph-bold ph-lg ti-fw"></i></MkButton> - <MkButton v-tooltip="i18n.ts.copyLink" class="button" rounded @click="copyLink"><i class="ph-share-network ph-bold ph-lg ti-fw"></i></MkButton> - <MkButton v-if="isSupportShare()" v-tooltip="i18n.ts.share" class="button" rounded @click="share"><i class="ph-share-network ph-bold ph-lg ti-fw"></i></MkButton> + <div class="items"> + <MkButton v-tooltip="i18n.ts.reload" class="button" rounded @click="reset"><i class="ph-arrows-clockwise ph-bold ph-lg"></i></MkButton> + </div> + <div class="items"> + <MkButton v-if="flash.isLiked" v-tooltip="i18n.ts.unlike" asLike class="button" rounded primary @click="unlike()"><i class="ph-heart ph-bold ph-lg"></i><span v-if="flash?.likedCount && flash.likedCount > 0" style="margin-left: 6px;">{{ flash.likedCount }}</span></MkButton> + <MkButton v-else v-tooltip="i18n.ts.like" asLike class="button" rounded @click="like()"><i class="ph-heart ph-bold ph-lg"></i><span v-if="flash?.likedCount && flash.likedCount > 0" style="margin-left: 6px;">{{ flash.likedCount }}</span></MkButton> + <MkButton v-tooltip="i18n.ts.copyLink" class="button" rounded @click="copyLink"><i class="ph-link ph-bold ph-lg ti-fw"></i></MkButton> + <MkButton v-tooltip="i18n.ts.share" class="button" rounded @click="share"><i class="ph-share-network ph-bold ph-lg ti-fw"></i></MkButton> + </div> </div> </div> <div v-else :class="$style.ready"> @@ -49,7 +53,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkA v-if="$i && $i.id === flash.userId" :to="`/play/${flash.id}/edit`" style="color: var(--accent);">{{ i18n.ts._play.editThisPage }}</MkA> <MkAd :prefer="['horizontal', 'horizontal-big']"/> </div> - <MkError v-else-if="error" @retry="fetchPage()"/> + <MkError v-else-if="error" @retry="fetchFlash()"/> <MkLoading v-else/> </Transition> </MkSpacer> @@ -94,12 +98,33 @@ function fetchFlash() { }); } +function share(ev: MouseEvent) { + if (!flash.value) return; + + os.popupMenu([ + { + text: i18n.ts.shareWithNote, + icon: 'ph-repeat ph-bold ph-lg ti-fw', + action: shareWithNote, + }, + ...(isSupportShare() ? [{ + text: i18n.ts.share, + icon: 'ph-share-network ph-bold ph-lg ti-fw', + action: shareWithNavigator, + }] : []), + ], ev.currentTarget ?? ev.target); + } + function copyLink() { + if (!flash.value) return; + copyToClipboard(`${url}/play/${flash.value.id}`); os.success(); } -function share() { +function shareWithNavigator() { + if (!flash.value) return; + navigator.share({ title: flash.value.title, text: flash.value.summary, @@ -108,21 +133,28 @@ function share() { } function shareWithNote() { + if (!flash.value) return; + os.post({ - initialText: `${flash.value.title} ${url}/play/${flash.value.id}`, + initialText: `${flash.value.title}\n${url}/play/${flash.value.id}`, + instant: true, }); } function like() { + if (!flash.value) return; + os.apiWithDialog('flash/like', { flashId: flash.value.id, }).then(() => { - flash.value.isLiked = true; - flash.value.likedCount++; + flash.value!.isLiked = true; + flash.value!.likedCount++; }); } async function unlike() { + if (!flash.value) return; + const confirm = await os.confirm({ type: 'warning', text: i18n.ts.unlikeConfirm, @@ -131,8 +163,8 @@ async function unlike() { os.apiWithDialog('flash/unlike', { flashId: flash.value.id, }).then(() => { - flash.value.isLiked = false; - flash.value.likedCount--; + flash.value!.isLiked = false; + flash.value!.likedCount--; }); } @@ -152,6 +184,7 @@ function start() { async function run() { if (aiscript.value) aiscript.value.abort(); + if (!flash.value) return; aiscript.value = new Interpreter({ ...createAiScriptEnv({ @@ -193,12 +226,17 @@ async function run() { } } -onDeactivated(() => { +function reset() { if (aiscript.value) aiscript.value.abort(); + started.value = false; +} + +onDeactivated(() => { + reset(); }); onUnmounted(() => { - if (aiscript.value) aiscript.value.abort(); + reset(); }); const headerActions = computed(() => []); @@ -265,11 +303,19 @@ definePageMetadata(() => ({ } > .actions { - display: flex; - justify-content: center; - gap: 12px; margin-top: 16px; - padding: 16px; + + > .items { + display: flex; + justify-content: center; + gap: 12px; + padding: 16px; + border-bottom: 1px solid var(--divider); + + &:last-child { + border-bottom: none; + } + } } } } diff --git a/packages/frontend/src/pages/instance-info.vue b/packages/frontend/src/pages/instance-info.vue index 4099e2bac9..0cc4c1a48a 100644 --- a/packages/frontend/src/pages/instance-info.vue +++ b/packages/frontend/src/pages/instance-info.vue @@ -35,7 +35,16 @@ SPDX-License-Identifier: AGPL-3.0-only <FormSection v-if="iAmModerator"> <template #label>Moderation</template> <div class="_gaps_s"> - <MkSwitch v-model="suspended" :disabled="!instance" @update:modelValue="toggleSuspend">{{ i18n.ts.stopActivityDelivery }}</MkSwitch> + <MkKeyValue> + <template #key> + {{ i18n.ts._delivery.status }} + </template> + <template #value> + {{ i18n.ts._delivery._type[suspensionState] }} + </template> + </MkKeyValue> + <MkButton v-if="suspensionState === 'none'" :disabled="!instance" danger @click="stopDelivery">{{ i18n.ts._delivery.stop }}</MkButton> + <MkButton v-if="suspensionState !== 'none'" :disabled="!instance" @click="resumeDelivery">{{ i18n.ts._delivery.resume }}</MkButton> <MkSwitch v-model="isBlocked" :disabled="!meta || !instance" @update:modelValue="toggleBlock">{{ i18n.ts.blockThisInstance }}</MkSwitch> <MkSwitch v-model="isSilenced" :disabled="!meta || !instance" @update:modelValue="toggleSilenced">{{ i18n.ts.silenceThisInstance }}</MkSwitch> <MkSwitch v-model="isNSFW" :disabled="!instance" @update:modelValue="toggleNSFW">Mark as NSFW</MkSwitch> @@ -156,7 +165,7 @@ const tab = ref('overview'); const chartSrc = ref('instance-requests'); const meta = ref<Misskey.entities.AdminMetaResponse | null>(null); const instance = ref<Misskey.entities.FederationInstance | null>(null); -const suspended = ref(false); +const suspensionState = ref<'none' | 'manuallySuspended' | 'goneSuspended' | 'autoSuspendedForNotResponding'>('none'); const isBlocked = ref(false); const isSilenced = ref(false); const isNSFW = ref(false); @@ -185,7 +194,7 @@ async function fetch(): Promise<void> { instance.value = await misskeyApi('federation/show-instance', { host: props.host, }); - suspended.value = instance.value?.isSuspended ?? false; + suspensionState.value = instance.value?.suspensionState ?? 'none'; isBlocked.value = instance.value?.isBlocked ?? false; isSilenced.value = instance.value?.isSilenced ?? false; isNSFW.value = instance.value?.isNSFW ?? false; @@ -212,11 +221,21 @@ async function toggleSilenced(): Promise<void> { }); } -async function toggleSuspend(): Promise<void> { +async function stopDelivery(): Promise<void> { if (!instance.value) throw new Error('No instance?'); + suspensionState.value = 'manuallySuspended'; await misskeyApi('admin/federation/update-instance', { host: instance.value.host, - isSuspended: suspended.value, + isSuspended: true, + }); +} + +async function resumeDelivery(): Promise<void> { + if (!instance.value) throw new Error('No instance?'); + suspensionState.value = 'none'; + await misskeyApi('admin/federation/update-instance', { + host: instance.value.host, + isSuspended: false, }); } diff --git a/packages/frontend/src/pages/my-antennas/create.vue b/packages/frontend/src/pages/my-antennas/create.vue index f511c48a06..ee2330a4f7 100644 --- a/packages/frontend/src/pages/my-antennas/create.vue +++ b/packages/frontend/src/pages/my-antennas/create.vue @@ -26,6 +26,7 @@ const draft = ref({ users: [], keywords: [], excludeKeywords: [], + excludeBots: false, withReplies: false, caseSensitive: false, localOnly: false, diff --git a/packages/frontend/src/pages/my-antennas/editor.vue b/packages/frontend/src/pages/my-antennas/editor.vue index 2d29e2d375..51dbb3b08f 100644 --- a/packages/frontend/src/pages/my-antennas/editor.vue +++ b/packages/frontend/src/pages/my-antennas/editor.vue @@ -26,6 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label>{{ i18n.ts.users }}</template> <template #caption>{{ i18n.ts.antennaUsersDescription }} <button class="_textButton" @click="addUser">{{ i18n.ts.addUser }}</button></template> </MkTextarea> + <MkSwitch v-model="excludeBots">{{ i18n.ts.antennaExcludeBots }}</MkSwitch> <MkSwitch v-model="withReplies">{{ i18n.ts.withReplies }}</MkSwitch> <MkTextarea v-model="keywords"> <template #label>{{ i18n.ts.antennaKeywords }}</template> @@ -38,7 +39,6 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSwitch v-model="localOnly">{{ i18n.ts.localOnly }}</MkSwitch> <MkSwitch v-model="caseSensitive">{{ i18n.ts.caseSensitive }}</MkSwitch> <MkSwitch v-model="withFile">{{ i18n.ts.withFileAntenna }}</MkSwitch> - <MkSwitch v-model="notify">{{ i18n.ts.notifyAntenna }}</MkSwitch> </div> <div :class="$style.actions"> <MkButton inline primary @click="saveAntenna()"><i class="ph-floppy-disk ph-bold ph-lg"></i> {{ i18n.ts.save }}</MkButton> @@ -78,9 +78,9 @@ const keywords = ref<string>(props.antenna.keywords.map(x => x.join(' ')).join(' const excludeKeywords = ref<string>(props.antenna.excludeKeywords.map(x => x.join(' ')).join('\n')); const caseSensitive = ref<boolean>(props.antenna.caseSensitive); const localOnly = ref<boolean>(props.antenna.localOnly); +const excludeBots = ref<boolean>(props.antenna.excludeBots); const withReplies = ref<boolean>(props.antenna.withReplies); const withFile = ref<boolean>(props.antenna.withFile); -const notify = ref<boolean>(props.antenna.notify); const userLists = ref<Misskey.entities.UserList[] | null>(null); watch(() => src.value, async () => { @@ -94,9 +94,9 @@ async function saveAntenna() { name: name.value, src: src.value, userListId: userListId.value, + excludeBots: excludeBots.value, withReplies: withReplies.value, withFile: withFile.value, - notify: notify.value, caseSensitive: caseSensitive.value, localOnly: localOnly.value, users: users.value.trim().split('\n').map(x => x.trim()), diff --git a/packages/frontend/src/pages/my-clips/index.vue b/packages/frontend/src/pages/my-clips/index.vue index f46ea0e0ea..1d5c134b27 100644 --- a/packages/frontend/src/pages/my-clips/index.vue +++ b/packages/frontend/src/pages/my-clips/index.vue @@ -11,16 +11,12 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="tab === 'my'" key="my" class="_gaps"> <MkButton primary rounded class="add" @click="create"><i class="ph-plus ph-bold ph-lg"></i> {{ i18n.ts.add }}</MkButton> - <MkPagination v-slot="{items}" ref="pagingComponent" :pagination="pagination" class="_gaps"> - <MkA v-for="item in items" :key="item.id" :to="`/clips/${item.id}`"> - <MkClipPreview :clip="item"/> - </MkA> + <MkPagination v-slot="{ items }" ref="pagingComponent" :pagination="pagination" class="_gaps"> + <MkClipPreview v-for="item in items" :key="item.id" :clip="item"/> </MkPagination> </div> <div v-else-if="tab === 'favorites'" key="favorites" class="_gaps"> - <MkA v-for="item in favorites" :key="item.id" :to="`/clips/${item.id}`"> - <MkClipPreview :clip="item"/> - </MkA> + <MkClipPreview v-for="item in favorites" :key="item.id" :clip="item"/> </div> </MkHorizontalSwipe> </MkSpacer> diff --git a/packages/frontend/src/pages/note.vue b/packages/frontend/src/pages/note.vue index 6ccb5b61e5..418b33754b 100644 --- a/packages/frontend/src/pages/note.vue +++ b/packages/frontend/src/pages/note.vue @@ -21,18 +21,16 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div v-if="defaultStore.state.noteDesign === 'misskey'" class="_margin _gaps_s"> <MkRemoteCaution v-if="note.user.host != null" :href="note.url ?? note.uri"/> - <MkNoteDetailed :key="note.id" v-model:note="note" :class="$style.note" :expandAllCws="expandAllCws"/> + <MkNoteDetailed :key="note.id" v-model:note="note" :initialTab="initialTab" :class="$style.note" :expandAllCws="expandAllCws"/> </div> <div v-else-if="defaultStore.state.noteDesign === 'sharkey'" class="_margin _gaps_s"> <MkRemoteCaution v-if="note.user.host != null" :href="note.url ?? note.uri"/> - <SkNoteDetailed :key="note.id" v-model:note="note" :class="$style.note" :expandAllCws="expandAllCws"/> + <SkNoteDetailed :key="note.id" v-model:note="note" :initialTab="initialTab" :class="$style.note" :expandAllCws="expandAllCws"/> </div> <div v-if="clips && clips.length > 0" class="_margin"> <div style="font-weight: bold; padding: 12px;">{{ i18n.ts.clip }}</div> <div class="_gaps"> - <MkA v-for="item in clips" :key="item.id" :to="`/clips/${item.id}`"> - <MkClipPreview :clip="item"/> - </MkA> + <MkClipPreview v-for="item in clips" :key="item.id" :clip="item"/> </div> </div> <div v-if="!showPrev" class="_buttons" :class="$style.loadPrev"> @@ -71,6 +69,7 @@ import { defaultStore } from '@/store.js'; const props = defineProps<{ noteId: string; + initialTab?: string; }>(); const note = ref<null | Misskey.entities.Note>(); diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue index 978d03c1cd..0bbe256cbf 100644 --- a/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue +++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only <XContainer :draggable="true" @remove="() => $emit('remove')"> <template #header><i class="ph-note ph-bold ph-lg"></i> {{ i18n.ts._pages.blocks.note }}</template> - <section style="padding: 0 16px 0 16px;"> + <section style="padding: 16px;" class="_gaps_s"> <MkInput v-model="id"> <template #label>{{ i18n.ts._pages.blocks._note.id }}</template> <template #caption>{{ i18n.ts._pages.blocks._note.idDescription }}</template> diff --git a/packages/frontend/src/pages/page.vue b/packages/frontend/src/pages/page.vue index dc47f20bee..99b8b4143f 100644 --- a/packages/frontend/src/pages/page.vue +++ b/packages/frontend/src/pages/page.vue @@ -6,48 +6,80 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :displayBackButton="true" :tabs="headerTabs"/></template> - <MkSpacer :contentMax="700"> - <Transition :name="defaultStore.state.animation ? 'fade' : ''" mode="out-in"> - <div v-if="page" :key="page.id" class="xcukqgmh"> - <div class="main"> - <!-- - <div class="header"> - <h1>{{ page.title }}</h1> - </div> - --> - <div class="banner"> - <MkMediaImage - v-if="page.eyeCatchingImageId" - :image="page.eyeCatchingImage" - :cover="true" - :disableImageLink="true" - class="thumbnail" - /> + <MkSpacer :contentMax="800"> + <Transition + :enterActiveClass="defaultStore.state.animation ? $style.fadeEnterActive : ''" + :leaveActiveClass="defaultStore.state.animation ? $style.fadeLeaveActive : ''" + :enterFromClass="defaultStore.state.animation ? $style.fadeEnterFrom : ''" + :leaveToClass="defaultStore.state.animation ? $style.fadeLeaveTo : ''" + mode="out-in" + > + <div v-if="page" :key="page.id" class="_gaps"> + <div :class="$style.pageMain"> + <div :class="$style.pageBanner"> + <div :class="$style.pageBannerBgRoot"> + <MkImgWithBlurhash + v-if="page.eyeCatchingImageId" + :class="$style.pageBannerBg" + :hash="page.eyeCatchingImage?.blurhash" + :cover="true" + :forceBlurhash="true" + /> + <img + v-else-if="instance.backgroundImageUrl || instance.bannerUrl" + :class="[$style.pageBannerBg, $style.pageBannerBgFallback1]" + :src="getStaticImageUrl(instance.backgroundImageUrl ?? instance.bannerUrl!)" + /> + <div v-else :class="[$style.pageBannerBg, $style.pageBannerBgFallback2]"></div> + </div> + <div v-if="page.eyeCatchingImageId" :class="$style.pageBannerImage"> + <MkMediaImage + :image="page.eyeCatchingImage!" + :cover="true" + :disableImageLink="true" + :class="$style.thumbnail" + /> + </div> + <div :class="$style.pageBannerTitle" class="_gaps_s"> + <h1>{{ page.title || page.name }}</h1> + <div :class="$style.pageBannerTitleSub"> + <div v-if="page.user" :class="$style.pageBannerTitleUser"> + <MkAvatar :user="page.user" :class="$style.avatar" indicator link preview/> <MkA :to="`/@${username}`"><MkUserName :user="page.user" :nowrap="false"/></MkA> + </div> + <div :class="$style.pageBannerTitleSubActions"> + <MkA v-if="page.userId === $i?.id" v-tooltip="i18n.ts._pages.editThisPage" :to="`/pages/edit/${page.id}`" class="_button" :class="$style.generalActionButton"><i class="ph-pencil-simple ph-bold ph-lg"></i></MkA> + <button v-tooltip="i18n.ts.share" class="_button" :class="$style.generalActionButton" @click="share"><i class="ph-share-network ph-bold ph-lg ti-fw"></i></button> + </div> + </div> + </div> </div> - <div class="content"> + <div :class="$style.pageContent"> <XPage :page="page"/> </div> - <div class="actions"> - <div class="like"> + <div :class="$style.pageActions"> + <div> <MkButton v-if="page.isLiked" v-tooltip="i18n.ts._pages.unlike" class="button" asLike primary @click="unlike()"><i class="ph-heart-break ph-bold ph-lg"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton> <MkButton v-else v-tooltip="i18n.ts._pages.like" class="button" asLike @click="like()"><i class="ph-heart ph-bold ph-lg"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton> </div> - <div class="other"> - <button v-tooltip="i18n.ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="ph-rocket-launch ph-bold ph-lg ti-fw"></i></button> - <button v-tooltip="i18n.ts.copyLink" v-click-anime class="_button" @click="copyLink"><i class="ph-share-network ph-bold ph-lg ti-fw"></i></button> - <button v-if="isSupportShare()" v-tooltip="i18n.ts.share" v-click-anime class="_button" @click="share"><i class="ph-share-network ph-bold ph-lg ti-fw"></i></button> + <div :class="$style.other"> + <button v-tooltip="i18n.ts.copyLink" class="_button" :class="$style.generalActionButton" @click="copyLink"><i class="ph-link ph-bold ph-lg ti-fw"></i></button> + <button v-tooltip="i18n.ts.share" class="_button" :class="$style.generalActionButton" @click="share"><i class="ph-share-network ph-bold ph-lg ti-fw"></i></button> </div> </div> - <div class="user"> - <MkAvatar :user="page.user" class="avatar" link preview/> - <div class="name"> - <MkUserName :user="page.user" style="display: block;"/> - <MkAcct :user="page.user"/> - </div> - <MkFollowButton v-if="!$i || $i.id != page.user.id" :user="page.user" :inline="true" :transparent="false" :full="true" large class="koudoku"/> + <div :class="$style.pageUser"> + <MkAvatar :user="page.user" :class="$style.avatar" link preview/> + <MkA :to="`/@${username}`"> + <MkUserName :user="page.user" :class="$style.name"/> + <MkAcct :user="page.user" :class="$style.acct"/> + </MkA> + <MkFollowButton v-if="!$i || $i.id != page.user.id" :user="page.user!" :inline="true" :transparent="false" :full="true" :class="$style.follow"/> </div> - <div class="links"> - <MkA :to="`/@${username}/pages/${pageName}/view-source`" class="link">{{ i18n.ts._pages.viewSource }}</MkA> + <div :class="$style.pageDate"> + <div><i class="ph ph-clock ph-bold ph-lg"></i> {{ i18n.ts.createdAt }}: <MkTime :time="page.createdAt" mode="detail"/></div> + <div v-if="page.createdAt != page.updatedAt"><i class="ph-pencil-simple ph-bold ph-lg"></i> {{ i18n.ts.updatedAt }}: <MkTime :time="page.updatedAt" mode="detail"/></div> + </div> + <div :class="$style.pageLinks"> + <MkA v-if="!$i || $i.id !== page.userId" :to="`/@${username}/pages/${pageName}/view-source`" class="link">{{ i18n.ts._pages.viewSource }}</MkA> <template v-if="$i && $i.id === page.userId"> <MkA :to="`/pages/edit/${page.id}`" class="link">{{ i18n.ts._pages.editThisPage }}</MkA> <button v-if="$i.pinnedPageId === page.id" class="link _textButton" @click="pin(false)">{{ i18n.ts.unpin }}</button> @@ -55,10 +87,6 @@ SPDX-License-Identifier: AGPL-3.0-only </template> </div> </div> - <div class="footer"> - <div><i class="ph-clock ph-bold ph-lg"></i> {{ i18n.ts.createdAt }}: <MkTime :time="page.createdAt" mode="detail"/></div> - <div v-if="page.createdAt != page.updatedAt"><i class="ph-clock ph-bold ph-lg"></i> {{ i18n.ts.updatedAt }}: <MkTime :time="page.updatedAt" mode="detail"/></div> - </div> <MkAd :prefer="['horizontal', 'horizontal-big']"/> <MkContainer :max-height="300" :foldable="true" class="other"> <template #icon><i class="ph-clock ph-bold ph-lg"></i></template> @@ -84,6 +112,7 @@ import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { url } from '@/config.js'; import MkMediaImage from '@/components/MkMediaImage.vue'; +import MkImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; import MkFollowButton from '@/components/MkFollowButton.vue'; import MkContainer from '@/components/MkContainer.vue'; import MkPagination from '@/components/MkPagination.vue'; @@ -94,6 +123,8 @@ import { pageViewInterruptors, defaultStore } from '@/store.js'; import { deepClone } from '@/scripts/clone.js'; import { $i } from '@/account.js'; import { isSupportShare } from '@/scripts/navigator.js'; +import { instance } from '@/instance.js'; +import { getStaticImageUrl } from '@/scripts/media-proxy.js'; import copyToClipboard from '@/scripts/copy-to-clipboard.js'; const props = defineProps<{ @@ -133,35 +164,63 @@ function fetchPage() { }); } -function share() { - navigator.share({ - title: page.value.title ?? page.value.name, - text: page.value.summary, - url: `${url}/@${page.value.user.username}/pages/${page.value.name}`, - }); +function share(ev: MouseEvent) { + if (!page.value) return; + + os.popupMenu([ + { + text: i18n.ts.shareWithNote, + icon: 'ph-pencil-simple', + action: shareWithNote, + }, + ...(isSupportShare() ? [{ + text: i18n.ts.share, + icon: 'ph-share-network', + action: shareWithNavigator, + }] : []), + ], ev.currentTarget ?? ev.target); } function copyLink() { + if (!page.value) return; + copyToClipboard(`${url}/@${page.value.user.username}/pages/${page.value.name}`); os.success(); } function shareWithNote() { + if (!page.value) return; + os.post({ - initialText: `${page.value.title || page.value.name} ${url}/@${page.value.user.username}/pages/${page.value.name}`, + initialText: `${page.value.title || page.value.name}\n${url}/@${page.value.user.username}/pages/${page.value.name}`, + instant: true, + }); +} + +function shareWithNavigator() { + if (!page.value) return; + + navigator.share({ + title: page.value.title ?? page.value.name, + text: page.value.summary ?? undefined, + url: `${url}/@${page.value.user.username}/pages/${page.value.name}`, }); } function like() { + if (!page.value) return; + os.apiWithDialog('pages/like', { pageId: page.value.id, }).then(() => { - page.value.isLiked = true; - page.value.likedCount++; + page.value!.isLiked = true; + page.value!.likedCount++; }); } async function unlike() { + if (!page.value) return; + const confirm = await os.confirm({ type: 'warning', text: i18n.ts.unlikeConfirm, @@ -170,12 +229,14 @@ async function unlike() { os.apiWithDialog('pages/unlike', { pageId: page.value.id, }).then(() => { - page.value.isLiked = false; - page.value.likedCount--; + page.value!.isLiked = false; + page.value!.likedCount--; }); } function pin(pin) { + if (!page.value) return; + os.apiWithDialog('i/update', { pinnedPageId: pin ? page.value.id : null, }); @@ -200,109 +261,200 @@ definePageMetadata(() => ({ })); </script> -<style lang="scss" scoped> -.fade-enter-active, -.fade-leave-active { +<style lang="scss" module> +.fadeEnterActive, +.fadeLeaveActive { transition: opacity 0.125s ease; } -.fade-enter-from, -.fade-leave-to { +.fadeEnterFrom, +.fadeLeaveTo { opacity: 0; } -.xcukqgmh { - > .main { - padding: 32px; +.generalActionButton { + height: 2.5rem; + width: 2.5rem; + text-align: center; + border-radius: 99rem; - > .header { - padding: 16px; + & :global(.ti) { + line-height: 2.5rem; + } - > h1 { - margin: 0; - } + &:hover, + &:focus-visible { + background-color: var(--accentedBg); + color: var(--accent); + text-decoration: none; + } +} + +.pageMain { + border-radius: var(--radius); + padding: 2rem; + background: var(--panel); + box-sizing: border-box; +} + +.pageBanner { + width: calc(100% + 4rem); + margin: -2rem -2rem 1.5rem; + border-radius: var(--radius) var(--radius) 0 0; + overflow: hidden; + position: relative; + + > .pageBannerBgRoot { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + overflow: hidden; + + .pageBannerBg { + width: 100%; + height: 100%; + object-fit: cover; + opacity: .2; + filter: brightness(1.2); } - > .banner { - > .thumbnail { - // TODO: 良い感じのアスペクト比で表示 - display: block; - width: 100%; - height: auto; - aspect-ratio: 3/1; - border-radius: var(--radius); - overflow: hidden; - object-fit: cover; - } + .pageBannerBgFallback1 { + filter: blur(20px); } - > .content { - margin-top: 16px; - padding: 16px 0 0 0; + .pageBannerBgFallback2 { + background-color: var(--accentedBg); } - > .actions { - display: flex; - align-items: center; - margin-top: 16px; - padding: 16px 0 0 0; - border-top: solid 0.5px var(--divider); + &::after { + content: ''; + position: absolute; + left: 0; + bottom: 0; + width: 100%; + height: 100px; + background: linear-gradient(0deg, var(--panel), transparent); + } + } - > .other { - margin-left: auto; + > .pageBannerImage { + position: relative; + padding-top: 56.25%; - > button { - padding: 8px; - margin: 0 8px; + > .thumbnail { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } + } - &:hover { - color: var(--fgHighlighted); - } - } - } + > .pageBannerTitle { + position: relative; + padding: 1.5rem 2rem; + + h1 { + font-size: 2rem; + font-weight: 700; + color: var(--fg); + margin: 0; } - > .user { - margin-top: 16px; - padding: 16px 0 0 0; - border-top: solid 0.5px var(--divider); + .pageBannerTitleSub { display: flex; align-items: center; + width: 100%; + } - > .avatar { - width: 52px; - height: 52px; - } + .pageBannerTitleUser { + --height: 32px; + flex-shrink: 0; - > .name { - margin: 0 0 0 12px; - font-size: 90%; + .avatar { + height: var(--height); + width: var(--height); } - > .koudoku { - margin-left: auto; - } + line-height: var(--height); } - > .links { - margin-top: 16px; - padding: 24px 0 0 0; - border-top: solid 0.5px var(--divider); - - > .link { - margin-right: 0.75em; - } + .pageBannerTitleSubActions { + flex-shrink: 0; + display: flex; + align-items: center; + gap: var(--marginHalf); + margin-left: auto; } } +} + +.pageContent { + margin-bottom: 1.5rem; +} + +.pageActions { + display: flex; + align-items: center; + + border-top: 1px solid var(--divider); + padding-top: 1.5rem; + margin-bottom: 1.5rem; + + > .other { + margin-left: auto; + display: flex; + gap: var(--marginHalf); + } +} + +.pageUser { + display: flex; + align-items: center; + + border-top: 1px solid var(--divider); + padding-top: 1.5rem; + margin-bottom: 1.5rem; + + .avatar, + .name, + .acct { + display: block; + } + + .avatar { + width: 4rem; + height: 4rem; + margin-right: 1rem; + } - > .footer { - margin: var(--margin) 0 var(--margin) 0; - font-size: 85%; - opacity: 0.75; + .name { + font-size: 110%; + font-weight: 700; + } + + .acct { + font-size: 90%; + opacity: 0.7; + } + + .follow { + margin-left: auto; } } -</style> -<style module> +.pageDate { + margin-bottom: 1.5rem; +} + +.pageLinks { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--marginHalf); +} + .relatedPagesRoot { padding: var(--margin); } diff --git a/packages/frontend/src/pages/reversi/game.board.vue b/packages/frontend/src/pages/reversi/game.board.vue index c5c3d491fc..af0309b699 100644 --- a/packages/frontend/src/pages/reversi/game.board.vue +++ b/packages/frontend/src/pages/reversi/game.board.vue @@ -151,6 +151,7 @@ import MkSwitch from '@/components/MkSwitch.vue'; import { deepClone } from '@/scripts/clone.js'; import { useInterval } from '@/scripts/use-interval.js'; import { signinRequired } from '@/account.js'; +import { url } from '@/config.js'; import { i18n } from '@/i18n.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { userPage } from '@/filters/user.js'; @@ -442,7 +443,7 @@ function autoplay() { function share() { os.post({ - initialText: `#MisskeyReversi ${location.href}`, + initialText: `#MisskeyReversi\n${url}/reversi/g/${game.value.id}`, instant: true, }); } diff --git a/packages/frontend/src/pages/settings/2fa.qrdialog.vue b/packages/frontend/src/pages/settings/2fa.qrdialog.vue index 13f475c2f2..857e28e1a1 100644 --- a/packages/frontend/src/pages/settings/2fa.qrdialog.vue +++ b/packages/frontend/src/pages/settings/2fa.qrdialog.vue @@ -25,6 +25,8 @@ SPDX-License-Identifier: AGPL-3.0-only <div style="height: 100cqh; overflow: auto; text-align: center;"> <MkSpacer :marginMin="20" :marginMax="28"> <div class="_gaps"> + <MkInfo><MkLink url="https://misskey-hub.net/docs/for-users/stepped-guides/how-to-enable-2fa/" target="_blank">{{ i18n.ts._2fa.moreDetailedGuideHere }}</MkLink></MkInfo> + <I18n :src="i18n.ts._2fa.step1" tag="div"> <template #a> <a href="https://authy.com/" rel="noopener" target="_blank" class="_link">Authy</a> @@ -33,8 +35,12 @@ SPDX-License-Identifier: AGPL-3.0-only <a href="https://support.google.com/accounts/answer/1066447" rel="noopener" target="_blank" class="_link">Google Authenticator</a> </template> </I18n> - <div>{{ i18n.ts._2fa.step2 }}<br>{{ i18n.ts._2fa.step2Click }}</div> - <a :href="twoFactorData.url"><img :class="$style.qr" :src="twoFactorData.qr"></a> + <div>{{ i18n.ts._2fa.step2 }}</div> + <div> + <a :class="$style.qrRoot" :href="twoFactorData.url"><img :class="$style.qr" :src="twoFactorData.qr"></a> + <!-- QRコード側にマージンが入っているので直下でOK --> + <div><MkButton inline rounded link :to="twoFactorData.url" :linkBehavior="'browser'">{{ i18n.ts.launchApp }}</MkButton></div> + </div> <MkKeyValue :copy="twoFactorData.url"> <template #key>{{ i18n.ts._2fa.step2Uri }}</template> <template #value>{{ twoFactorData.url }}</template> @@ -52,7 +58,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSpacer :marginMin="20" :marginMax="28"> <div class="_gaps"> <div>{{ i18n.ts._2fa.step3Title }}</div> - <MkInput v-model="token" autocomplete="one-time-code"></MkInput> + <MkInput v-model="token" autocomplete="one-time-code" inputmode="numeric"></MkInput> <div>{{ i18n.ts._2fa.step3 }}</div> </div> <div class="_buttonsCenter" style="margin-top: 16px;"> @@ -109,6 +115,7 @@ import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; import MkFolder from '@/components/MkFolder.vue'; import MkInfo from '@/components/MkInfo.vue'; +import MkLink from '@/components/MkLink.vue'; import { confetti } from '@/scripts/confetti.js'; import { signinRequired } from '@/account.js'; @@ -177,8 +184,14 @@ function allDone() { transform: translateX(-50px); } -.qr { +.qrRoot { + display: block; + margin: 0 auto; width: 200px; max-width: 100%; } + +.qr { + width: 100%; +} </style> diff --git a/packages/frontend/src/pages/settings/2fa.vue b/packages/frontend/src/pages/settings/2fa.vue index ba85a43084..0396a3b085 100644 --- a/packages/frontend/src/pages/settings/2fa.vue +++ b/packages/frontend/src/pages/settings/2fa.vue @@ -30,7 +30,10 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton v-else danger @click="unregisterTOTP">{{ i18n.ts.unregister }}</MkButton> </div> - <MkButton v-else-if="!$i.twoFactorEnabled" primary gradate @click="registerTOTP">{{ i18n.ts._2fa.registerTOTP }}</MkButton> + <div v-else-if="!$i.twoFactorEnabled" class="_gaps_s"> + <MkButton primary gradate @click="registerTOTP">{{ i18n.ts._2fa.registerTOTP }}</MkButton> + <MkLink url="https://misskey-hub.net/docs/for-users/stepped-guides/how-to-enable-2fa/" target="_blank"><i class="ph-question ph-bold ph-lg"></i> {{ i18n.ts.learnMore }}</MkLink> + </div> </MkFolder> <MkFolder> @@ -79,8 +82,9 @@ import MkInfo from '@/components/MkInfo.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import FormSection from '@/components/form/section.vue'; import MkFolder from '@/components/MkFolder.vue'; +import MkLink from '@/components/MkLink.vue'; import * as os from '@/os.js'; -import { signinRequired } from '@/account.js'; +import { signinRequired, updateAccount } from '@/account.js'; import { i18n } from '@/i18n.js'; const $i = signinRequired(); @@ -116,6 +120,10 @@ async function unregisterTOTP(): Promise<void> { os.apiWithDialog('i/2fa/unregister', { password: auth.result.password, token: auth.result.token, + }).then(res => { + updateAccount({ + twoFactorEnabled: false, + }); }).catch(error => { os.alert({ type: 'error', diff --git a/packages/frontend/src/pages/settings/drive.vue b/packages/frontend/src/pages/settings/drive.vue index 4185a1c855..06de4163b6 100644 --- a/packages/frontend/src/pages/settings/drive.vue +++ b/packages/frontend/src/pages/settings/drive.vue @@ -44,6 +44,10 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label>{{ i18n.ts.keepOriginalUploading }}</template> <template #caption>{{ i18n.ts.keepOriginalUploadingDescription }}</template> </MkSwitch> + <MkSwitch v-model="keepOriginalFilename"> + <template #label>{{ i18n.ts.keepOriginalFilename }}</template> + <template #caption>{{ i18n.ts.keepOriginalFilenameDescription }}</template> + </MkSwitch> <MkSwitch v-model="alwaysMarkNsfw" @update:modelValue="saveProfile()"> <template #label>{{ i18n.ts.alwaysMarkSensitive }}</template> </MkSwitch> @@ -91,6 +95,7 @@ const meterStyle = computed(() => { }); const keepOriginalUploading = computed(defaultStore.makeGetterSetter('keepOriginalUploading')); +const keepOriginalFilename = computed(defaultStore.makeGetterSetter('keepOriginalFilename')); misskeyApi('drive').then(info => { capacity.value = info.capacity; diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue index f0185c8dcd..d7bb6156e4 100644 --- a/packages/frontend/src/pages/settings/general.vue +++ b/packages/frontend/src/pages/settings/general.vue @@ -50,17 +50,21 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_gaps_m"> <div class="_gaps_s"> - <MkSwitch v-model="showNoteActionsOnlyHover">{{ i18n.ts.showNoteActionsOnlyHover }}</MkSwitch> - <MkSwitch v-model="showClipButtonInNoteFooter">{{ i18n.ts.showClipButtonInNoteFooter }}</MkSwitch> - <MkSwitch v-model="collapseRenotes">{{ i18n.ts.collapseRenotes }}</MkSwitch> + <MkSwitch v-model="collapseRenotes"> + <template #label>{{ i18n.ts.collapseRenotes }}</template> + <template #caption>{{ i18n.ts.collapseRenotesDescription }}</template> + </MkSwitch> <MkSwitch v-model="collapseNotesRepliedTo">{{ i18n.ts.collapseNotesRepliedTo }}</MkSwitch> <MkSwitch v-model="collapseFiles">{{ i18n.ts.collapseFiles }}</MkSwitch> <MkSwitch v-model="uncollapseCW">Uncollapse CWs on notes</MkSwitch> - <MkSwitch v-model="autoloadConversation">{{ i18n.ts.autoloadConversation }}</MkSwitch> <MkSwitch v-model="expandLongNote">Always expand long notes</MkSwitch> + <MkSwitch v-model="showNoteActionsOnlyHover">{{ i18n.ts.showNoteActionsOnlyHover }}</MkSwitch> + <MkSwitch v-model="showClipButtonInNoteFooter">{{ i18n.ts.showClipButtonInNoteFooter }}</MkSwitch> + <MkSwitch v-model="autoloadConversation">{{ i18n.ts.autoloadConversation }}</MkSwitch> <MkSwitch v-model="advancedMfm">{{ i18n.ts.enableAdvancedMfm }}</MkSwitch> <MkSwitch v-if="advancedMfm" v-model="animatedMfm">{{ i18n.ts.enableAnimatedMfm }}</MkSwitch> <MkSwitch v-if="advancedMfm" v-model="enableQuickAddMfmFunction">{{ i18n.ts.enableQuickAddMfmFunction }}</MkSwitch> + <MkSwitch v-model="showReactionsCount">{{ i18n.ts.showReactionsCount }}</MkSwitch> <MkSwitch v-model="showGapBetweenNotesInTimeline">{{ i18n.ts.showGapBetweenNotesInTimeline }}</MkSwitch> <MkSwitch v-model="loadRawImages">{{ i18n.ts.loadRawImages }}</MkSwitch> <MkSwitch v-model="showTickerOnReplies">Show instance ticker on replies</MkSwitch> @@ -172,6 +176,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSwitch v-model="forceShowAds">{{ i18n.ts.forceShowAds }}</MkSwitch> <MkSwitch v-model="oneko">{{ i18n.ts.oneko }}</MkSwitch> <MkSwitch v-model="enableSeasonalScreenEffect">{{ i18n.ts.seasonalScreenEffect }}</MkSwitch> + <MkSwitch v-model="useNativeUIForVideoAudioPlayer">{{ i18n.ts.useNativeUIForVideoAudioPlayer }}</MkSwitch> </div> <div> <MkRadios v-model="emojiStyle"> @@ -214,6 +219,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSwitch v-model="showBots">{{ i18n.ts.showBots }}</MkSwitch> <MkSwitch v-model="disableStreamingTimeline">{{ i18n.ts.disableStreamingTimeline }}</MkSwitch> <MkSwitch v-model="enableHorizontalSwipe">{{ i18n.ts.enableHorizontalSwipe }}</MkSwitch> + <MkSwitch v-model="alwaysConfirmFollow">{{ i18n.ts.alwaysConfirmFollow }}</MkSwitch> </div> <MkSelect v-model="serverDisconnectedBehavior"> <template #label>{{ i18n.ts.whenServerDisconnected }}</template> @@ -364,6 +370,7 @@ const useBlurEffect = computed(defaultStore.makeGetterSetter('useBlurEffect')); const showGapBetweenNotesInTimeline = computed(defaultStore.makeGetterSetter('showGapBetweenNotesInTimeline')); const animatedMfm = computed(defaultStore.makeGetterSetter('animatedMfm')); const advancedMfm = computed(defaultStore.makeGetterSetter('advancedMfm')); +const showReactionsCount = computed(defaultStore.makeGetterSetter('showReactionsCount')); const enableQuickAddMfmFunction = computed(defaultStore.makeGetterSetter('enableQuickAddMfmFunction')); const emojiStyle = computed(defaultStore.makeGetterSetter('emojiStyle')); const disableDrawer = computed(defaultStore.makeGetterSetter('disableDrawer')); @@ -402,6 +409,8 @@ const enableSeasonalScreenEffect = computed(defaultStore.makeGetterSetter('enabl const showVisibilitySelectorOnBoost = computed(defaultStore.makeGetterSetter('showVisibilitySelectorOnBoost')); const visibilityOnBoost = computed(defaultStore.makeGetterSetter('visibilityOnBoost')); const enableHorizontalSwipe = computed(defaultStore.makeGetterSetter('enableHorizontalSwipe')); +const useNativeUIForVideoAudioPlayer = computed(defaultStore.makeGetterSetter('useNativeUIForVideoAudioPlayer')); +const alwaysConfirmFollow = computed(defaultStore.makeGetterSetter('alwaysConfirmFollow')); watch(lang, () => { miLocalStorage.setItem('lang', lang.value as string); @@ -460,6 +469,7 @@ watch([ enableSeasonalScreenEffect, showVisibilitySelectorOnBoost, visibilityOnBoost, + alwaysConfirmFollow, ], async () => { await reloadAsk(); }); diff --git a/packages/frontend/src/pages/settings/plugin.vue b/packages/frontend/src/pages/settings/plugin.vue index f1699f726e..349015dd28 100644 --- a/packages/frontend/src/pages/settings/plugin.vue +++ b/packages/frontend/src/pages/settings/plugin.vue @@ -42,12 +42,25 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <MkFolder> + <template #icon><i class="ph-terminal-window ph-bold ph-lg"></i></template> + <template #label>{{ i18n.ts._plugin.viewLog }}</template> + + <div class="_gaps_s"> + <div class="_buttons"> + <MkButton inline @click="copy(pluginLogs.get(plugin.id)?.join('\n'))"><i class="ph-copy ph-bold ph-lg"></i> {{ i18n.ts.copy }}</MkButton> + </div> + + <MkCode :code="pluginLogs.get(plugin.id)?.join('\n') ?? ''"/> + </div> + </MkFolder> + + <MkFolder> <template #icon><i class="ph-code ph-bold ph-lg"></i></template> <template #label>{{ i18n.ts._plugin.viewSource }}</template> <div class="_gaps_s"> <div class="_buttons"> - <MkButton inline @click="copy(plugin)"><i class="ph-copy ph-bold ph-lg"></i> {{ i18n.ts.copy }}</MkButton> + <MkButton inline @click="copy(plugin.src)"><i class="ph-copy ph-bold ph-lg"></i> {{ i18n.ts.copy }}</MkButton> </div> <MkCode :code="plugin.src ?? ''" lang="is"/> @@ -74,6 +87,7 @@ import { ColdDeviceStorage } from '@/store.js'; import { unisonReload } from '@/scripts/unison-reload.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { pluginLogs } from '@/plugin.js'; const plugins = ref(ColdDeviceStorage.get('plugins')); @@ -87,8 +101,8 @@ async function uninstall(plugin) { }); } -function copy(plugin) { - copyToClipboard(plugin.src ?? ''); +function copy(text) { + copyToClipboard(text ?? ''); os.success(); } diff --git a/packages/frontend/src/pages/settings/preferences-backups.vue b/packages/frontend/src/pages/settings/preferences-backups.vue index eeedcca3c8..b99e1e03e4 100644 --- a/packages/frontend/src/pages/settings/preferences-backups.vue +++ b/packages/frontend/src/pages/settings/preferences-backups.vue @@ -71,6 +71,7 @@ const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [ 'animation', 'animatedMfm', 'advancedMfm', + 'showReactionsCount', 'loadRawImages', 'warnMissingAltText', 'enableFaviconNotificationDot', diff --git a/packages/frontend/src/pages/share.vue b/packages/frontend/src/pages/share.vue index 1eeeb587eb..8d974b17fb 100644 --- a/packages/frontend/src/pages/share.vue +++ b/packages/frontend/src/pages/share.vue @@ -64,7 +64,34 @@ async function init() { // Googleニュース対策 if (text?.startsWith(`${title.value}.\n`)) noteText += text.replace(`${title.value}.\n`, ''); else if (text && title.value !== text) noteText += `${text}\n`; - if (url) noteText += `${url}`; + if (url) { + try { + // Normalize the URL to URL-encoded and puny-coded from with the URL constructor. + // + // It's common to use unicode characters in the URL for better visibility of URL + // like: https://ja.wikipedia.org/wiki/ミスキー + // or like: https://藍.moe/ + // However, in the MFM, the unicode characters must be URL-encoded to be parsed as `url` node + // like: https://ja.wikipedia.org/wiki/%E3%83%9F%E3%82%B9%E3%82%AD%E3%83%BC + // or like: https://xn--931a.moe/ + // Therefore, we need to normalize the URL to URL-encoded form. + // + // The URL constructor will parse the URL and normalize unicode characters + // in the host to punycode and in the path component to URL-encoded form. + // (see url.spec.whatwg.org) + // + // In addition, the current MFM renderer decodes the URL-encoded path and / punycode encoded host name so + // this normalization doesn't make the visible URL ugly. + // (see MkUrl.vue) + + noteText += new URL(url).href; + } catch { + // fallback to original URL if the URL is invalid. + // note that this is extremely rare since the `url` parameter is designed to share a URL and + // the URL constructor will throw TypeError only if failure, which means the URL is not valid. + noteText += url; + } + } initialText.value = noteText.trim(); if (visibility.value === 'specified') { diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue index a9f7a163f6..1e5a244dd4 100644 --- a/packages/frontend/src/pages/timeline.vue +++ b/packages/frontend/src/pages/timeline.vue @@ -49,7 +49,7 @@ import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; import { $i } from '@/account.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { antennasCache, userListsCache } from '@/cache.js'; +import { antennasCache, userListsCache, favoritedChannelsCache } from '@/cache.js'; import { deviceKind } from '@/scripts/device-kind.js'; import { deepMerge } from '@/scripts/merge.js'; import { MenuItem } from '@/types/menu.js'; @@ -180,9 +180,7 @@ async function chooseAntenna(ev: MouseEvent): Promise<void> { } async function chooseChannel(ev: MouseEvent): Promise<void> { - const channels = await misskeyApi('channels/my-favorites', { - limit: 100, - }); + const channels = await favoritedChannelsCache.fetch(); const items: MenuItem[] = [ ...channels.map(channel => { const lastReadedAt = miLocalStorage.getItemAsJson(`channelLastReadedAt:${channel.id}`) ?? null; diff --git a/packages/frontend/src/pages/user-list-timeline.vue b/packages/frontend/src/pages/user-list-timeline.vue index b2d52b013c..c60e3c0f29 100644 --- a/packages/frontend/src/pages/user-list-timeline.vue +++ b/packages/frontend/src/pages/user-list-timeline.vue @@ -37,6 +37,7 @@ import { useRouter } from '@/router/supplier.js'; import { defaultStore } from '@/store.js'; import { deepMerge } from '@/scripts/merge.js'; import * as os from '@/os.js'; +import { $i } from '@/account.js'; const router = useRouter(); diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue index 48ff0d04bf..095645636d 100644 --- a/packages/frontend/src/pages/user/home.vue +++ b/packages/frontend/src/pages/user/home.vue @@ -253,7 +253,7 @@ if (props.user.listenbrainz) { } catch (err) { listenbrainzdata.value = false; } - })() + })(); } const background = computed(() => { diff --git a/packages/frontend/src/pages/user/index.listenbrainz.vue b/packages/frontend/src/pages/user/index.listenbrainz.vue index d58bd96347..965a029fac 100644 --- a/packages/frontend/src/pages/user/index.listenbrainz.vue +++ b/packages/frontend/src/pages/user/index.listenbrainz.vue @@ -1,3 +1,8 @@ +<!-- +SPDX-FileCopyrightText: amelia and other Sharkey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + <template> <MkContainer :foldable="true"> <template #header diff --git a/packages/frontend/src/pages/welcome.entrance.a.vue b/packages/frontend/src/pages/welcome.entrance.a.vue index 255e07c3fa..0d132e6a86 100644 --- a/packages/frontend/src/pages/welcome.entrance.a.vue +++ b/packages/frontend/src/pages/welcome.entrance.a.vue @@ -9,7 +9,10 @@ SPDX-License-Identifier: AGPL-3.0-only <XTimeline class="tl"/> <div class="shape1"></div> <div class="shape2"></div> - <img :src="misskeysvg" class="misskey"/> + <div class="logo-wrapper"> + <div class="powered-by">Powered by</div> + <img :src="misskeysvg" class="misskey"/> + </div> <div class="emojis"> <MkEmoji :normal="true" :noStyle="true" emoji="👍"/> <MkEmoji :normal="true" :noStyle="true" emoji="❤"/> @@ -39,11 +42,11 @@ import XTimeline from './welcome.timeline.vue'; import MarqueeText from '@/components/MkMarquee.vue'; import MkFeaturedPhotos from '@/components/MkFeaturedPhotos.vue'; import misskeysvg from '/client-assets/sharkey.svg'; -import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js'; +import { misskeyApiGet } from '@/scripts/misskey-api.js'; import MkVisitorDashboard from '@/components/MkVisitorDashboard.vue'; import { getProxiedImageUrl } from '@/scripts/media-proxy.js'; +import { instance as meta } from '@/instance.js'; -const meta = ref<Misskey.entities.MetaResponse>(); const instances = ref<Misskey.entities.FederationInstance[]>(); function getInstanceIcon(instance: Misskey.entities.FederationInstance): string { @@ -53,10 +56,6 @@ function getInstanceIcon(instance: Misskey.entities.FederationInstance): string return getProxiedImageUrl(instance.iconUrl, 'preview'); } -misskeyApi('meta', { detail: true }).then(_meta => { - meta.value = _meta; -}); - misskeyApiGet('federation/instances', { sort: '+pubSub', limit: 20, @@ -113,14 +112,24 @@ misskeyApiGet('federation/instances', { opacity: 0.5; } - > .misskey { + > .logo-wrapper { position: fixed; - top: 42px; - left: 42px; - width: 140px; + top: 36px; + left: 36px; + flex: auto; + color: #fff; + user-select: none; + pointer-events: none; + + > .powered-by { + margin-bottom: 2px; + } - @media (max-width: 450px) { - width: 130px; + > .misskey { + width: 140px; + @media (max-width: 450px) { + width: 130px; + } } } diff --git a/packages/frontend/src/pages/welcome.vue b/packages/frontend/src/pages/welcome.vue index 9ba6a5885e..915fe35025 100644 --- a/packages/frontend/src/pages/welcome.vue +++ b/packages/frontend/src/pages/welcome.vue @@ -4,8 +4,8 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div v-if="meta"> - <XSetup v-if="meta.requireSetup"/> +<div v-if="instance"> + <XSetup v-if="instance.requireSetup"/> <XEntrance v-else/> </div> </template> @@ -16,13 +16,13 @@ import * as Misskey from 'misskey-js'; import XSetup from './welcome.setup.vue'; import XEntrance from './welcome.entrance.a.vue'; import { instanceName } from '@/config.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { fetchInstance } from '@/instance.js'; -const meta = ref<Misskey.entities.MetaResponse | null>(null); +const instance = ref<Misskey.entities.MetaDetailed | null>(null); -misskeyApi('meta', { detail: true }).then(res => { - meta.value = res; +fetchInstance(true).then((res) => { + instance.value = res; }); const headerActions = computed(() => []); diff --git a/packages/frontend/src/plugin.ts b/packages/frontend/src/plugin.ts index 743cadc36a..81233a5a5e 100644 --- a/packages/frontend/src/plugin.ts +++ b/packages/frontend/src/plugin.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { ref } from 'vue'; import { Interpreter, Parser, utils, values } from '@syuilo/aiscript'; import { aiScriptReadline, createAiScriptEnv } from '@/scripts/aiscript/api.js'; import { inputText } from '@/os.js'; @@ -10,6 +11,7 @@ import { Plugin, noteActions, notePostInterruptors, noteViewInterruptors, postFo const parser = new Parser(); const pluginContexts = new Map<string, Interpreter>(); +export const pluginLogs = ref(new Map<string, string[]>()); export async function install(plugin: Plugin): Promise<void> { // 後方互換性のため @@ -22,21 +24,27 @@ export async function install(plugin: Plugin): Promise<void> { in: aiScriptReadline, out: (value): void => { console.log(value); + pluginLogs.value.get(plugin.id).push(utils.reprValue(value)); }, log: (): void => { }, + err: (err): void => { + pluginLogs.value.get(plugin.id).push(`${err}`); + throw err; // install時のtry-catchに反応させる + }, }); initPlugin({ plugin, aiscript }); - try { - await aiscript.exec(parser.parse(plugin.src)); - } catch (err) { - console.error('Plugin install failed:', plugin.name, 'v' + plugin.version); - return; - } - - console.info('Plugin installed:', plugin.name, 'v' + plugin.version); + aiscript.exec(parser.parse(plugin.src)).then( + () => { + console.info('Plugin installed:', plugin.name, 'v' + plugin.version); + }, + (err) => { + console.error('Plugin install failed:', plugin.name, 'v' + plugin.version); + throw err; + }, + ); } function createPluginEnv(opts: { plugin: Plugin; storageKey: string }): Record<string, values.Value> { @@ -92,6 +100,7 @@ function createPluginEnv(opts: { plugin: Plugin; storageKey: string }): Record<s function initPlugin({ plugin, aiscript }): void { pluginContexts.set(plugin.id, aiscript); + pluginLogs.value.set(plugin.id, []); } function registerPostFormAction({ pluginId, title, handler }): void { diff --git a/packages/frontend/src/router/definition.ts b/packages/frontend/src/router/definition.ts index c5fc28a345..f18dac2a44 100644 --- a/packages/frontend/src/router/definition.ts +++ b/packages/frontend/src/router/definition.ts @@ -35,7 +35,7 @@ const routes: RouteDef[] = [{ component: page(() => import('@/pages/user/index.vue')), }, { name: 'note', - path: '/notes/:noteId', + path: '/notes/:noteId/:initialTab?', component: page(() => import('@/pages/note.vue')), }, { name: 'list', @@ -194,10 +194,16 @@ const routes: RouteDef[] = [{ path: '/announcements', component: page(() => import('@/pages/announcements.vue')), }, { + path: '/announcements/:announcementId', + component: page(() => import('@/pages/announcement.vue')), +}, { path: '/about', component: page(() => import('@/pages/about.vue')), hash: 'initialTab', }, { + path: '/contact', + component: page(() => import('@/pages/contact.vue')), +}, { path: '/about-sharkey', component: page(() => import('@/pages/about-sharkey.vue')), }, { diff --git a/packages/frontend/src/scripts/lookup-user.ts b/packages/frontend/src/scripts/admin-lookup.ts index efc9132e75..1b57b853c9 100644 --- a/packages/frontend/src/scripts/lookup-user.ts +++ b/packages/frontend/src/scripts/admin-lookup.ts @@ -63,3 +63,26 @@ export async function lookupUserByEmail() { } } } + +export async function lookupFile() { + const { canceled, result: q } = await os.inputText({ + title: i18n.ts.fileIdOrUrl, + minLength: 1, + }); + if (canceled) return; + + const show = (file) => { + os.pageWindow(`/admin/file/${file.id}`); + }; + + misskeyApi('admin/drive/show-file', q.startsWith('http://') || q.startsWith('https://') ? { url: q.trim() } : { fileId: q.trim() }).then(file => { + show(file); + }).catch(err => { + if (err.code === 'NO_SUCH_FILE') { + os.alert({ + type: 'error', + text: i18n.ts.notFound, + }); + } + }); +} diff --git a/packages/frontend/src/scripts/aiscript/ui.ts b/packages/frontend/src/scripts/aiscript/ui.ts index f2493264d3..fa3fcac2e7 100644 --- a/packages/frontend/src/scripts/aiscript/ui.ts +++ b/packages/frontend/src/scripts/aiscript/ui.ts @@ -6,6 +6,7 @@ import { utils, values } from '@syuilo/aiscript'; import { v4 as uuid } from 'uuid'; import { ref, Ref } from 'vue'; +import * as Misskey from 'misskey-js'; export type AsUiComponentBase = { id: string; @@ -115,23 +116,24 @@ export type AsUiFolder = AsUiComponentBase & { opened?: boolean; }; +type PostFormPropsForAsUi = { + text: string; + cw?: string; + visibility?: (typeof Misskey.noteVisibilities)[number]; + localOnly?: boolean; +}; + export type AsUiPostFormButton = AsUiComponentBase & { type: 'postFormButton'; text?: string; primary?: boolean; rounded?: boolean; - form?: { - text: string; - cw?: string; - }; + form?: PostFormPropsForAsUi; }; export type AsUiPostForm = AsUiComponentBase & { type: 'postForm'; - form?: { - text: string; - cw?: string; - }; + form?: PostFormPropsForAsUi; }; export type AsUiComponent = AsUiRoot | AsUiContainer | AsUiText | AsUiMfm | AsUiButton | AsUiButtons | AsUiSwitch | AsUiTextarea | AsUiTextInput | AsUiNumberInput | AsUiSelect | AsUiFolder | AsUiPostFormButton | AsUiPostForm; @@ -447,6 +449,24 @@ function getFolderOptions(def: values.Value | undefined): Omit<AsUiFolder, 'id' }; } +function getPostFormProps(form: values.VObj): PostFormPropsForAsUi { + const text = form.value.get('text'); + utils.assertString(text); + const cw = form.value.get('cw'); + if (cw) utils.assertString(cw); + const visibility = form.value.get('visibility'); + if (visibility) utils.assertString(visibility); + const localOnly = form.value.get('localOnly'); + if (localOnly) utils.assertBoolean(localOnly); + + return { + text: text.value, + cw: cw?.value, + visibility: (visibility?.value && (Misskey.noteVisibilities as readonly string[]).includes(visibility.value)) ? visibility.value as typeof Misskey.noteVisibilities[number] : undefined, + localOnly: localOnly?.value, + }; +} + function getPostFormButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiPostFormButton, 'id' | 'type'> { utils.assertObject(def); @@ -459,22 +479,11 @@ function getPostFormButtonOptions(def: values.Value | undefined, call: (fn: valu const form = def.value.get('form'); if (form) utils.assertObject(form); - const getForm = () => { - const text = form!.value.get('text'); - utils.assertString(text); - const cw = form!.value.get('cw'); - if (cw) utils.assertString(cw); - return { - text: text.value, - cw: cw?.value, - }; - }; - return { text: text?.value, primary: primary?.value, rounded: rounded?.value, - form: form ? getForm() : { + form: form ? getPostFormProps(form) : { text: '', }, }; @@ -486,19 +495,8 @@ function getPostFormOptions(def: values.Value | undefined, call: (fn: values.VFn const form = def.value.get('form'); if (form) utils.assertObject(form); - const getForm = () => { - const text = form!.value.get('text'); - utils.assertString(text); - const cw = form!.value.get('cw'); - if (cw) utils.assertString(cw); - return { - text: text.value, - cw: cw?.value, - }; - }; - return { - form: form ? getForm() : { + form: form ? getPostFormProps(form) : { text: '', }, }; diff --git a/packages/frontend/src/scripts/check-animated-mfm.ts b/packages/frontend/src/scripts/check-animated-mfm.ts index eac18738ee..2614dfb4f1 100644 --- a/packages/frontend/src/scripts/check-animated-mfm.ts +++ b/packages/frontend/src/scripts/check-animated-mfm.ts @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: marie and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + import * as mfm from '@transfem-org/sfm-js'; export function checkAnimationFromMfm(nodes: mfm.MfmNode[]): boolean { @@ -8,10 +13,12 @@ export function checkAnimationFromMfm(nodes: mfm.MfmNode[]): boolean { node.props.name === 'twitch' || node.props.name === 'shake' || node.props.name === 'spin' || - node.props.name === 'jump' || - node.props.name === 'bounce' || - node.props.name === 'rainbow' || - node.props.name === 'sparkle') { + node.props.name === 'jump' || + node.props.name === 'bounce' || + node.props.name === 'rainbow' || + node.props.name === 'sparkle' || + node.props.name === 'fade' || + node.props.name === 'followmouse') { return true; } else { return false; @@ -20,7 +27,7 @@ export function checkAnimationFromMfm(nodes: mfm.MfmNode[]): boolean { return false; } }); - + if (animatedNodes.length > 0) { return true; } else { diff --git a/packages/frontend/src/scripts/check-reaction-permissions.ts b/packages/frontend/src/scripts/check-reaction-permissions.ts index e7b473dd75..8fc857f84f 100644 --- a/packages/frontend/src/scripts/check-reaction-permissions.ts +++ b/packages/frontend/src/scripts/check-reaction-permissions.ts @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + import * as Misskey from 'misskey-js'; import { UnicodeEmojiDef } from './emojilist.js'; diff --git a/packages/frontend/src/scripts/check-word-mute.ts b/packages/frontend/src/scripts/check-word-mute.ts index 8d3e96cea5..e65c327ffe 100644 --- a/packages/frontend/src/scripts/check-word-mute.ts +++ b/packages/frontend/src/scripts/check-word-mute.ts @@ -46,21 +46,21 @@ export function checkWordMute(note: Note, me: MeDetailed | null | undefined, mut function getNoteText(note: Note): string { const textParts: string[] = []; - if (note.cw) - textParts.push(note.cw); + if (note.cw) textParts.push(note.cw); - if (note.text) - textParts.push(note.text); + if (note.text) textParts.push(note.text); - if (note.files) - for (const file of note.files) - if (file.comment) - textParts.push(file.comment); + if (note.files) { + for (const file of note.files) { + if (file.comment) textParts.push(file.comment); + } + } - if (note.poll) - for (const choice of note.poll.choices) - if (choice.text) - textParts.push(choice.text); + if (note.poll) { + for (const choice of note.poll.choices) { + if (choice.text) textParts.push(choice.text); + } + } return textParts.join('\n').trim(); } diff --git a/packages/frontend/src/scripts/chiptune2.ts b/packages/frontend/src/scripts/chiptune2.ts index 56afb9b708..220002ff1e 100644 --- a/packages/frontend/src/scripts/chiptune2.ts +++ b/packages/frontend/src/scripts/chiptune2.ts @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: marie and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + /* eslint-disable */ const ChiptuneAudioContext = window.AudioContext || window.webkitAudioContext; diff --git a/packages/frontend/src/scripts/clear-cache.ts b/packages/frontend/src/scripts/clear-cache.ts index b20109ec72..71d1232710 100644 --- a/packages/frontend/src/scripts/clear-cache.ts +++ b/packages/frontend/src/scripts/clear-cache.ts @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + import { unisonReload } from '@/scripts/unison-reload.js'; import * as os from '@/os.js'; import { miLocalStorage } from '@/local-storage.js'; diff --git a/packages/frontend/src/scripts/code-highlighter.ts b/packages/frontend/src/scripts/code-highlighter.ts index 2733897bab..e94027d302 100644 --- a/packages/frontend/src/scripts/code-highlighter.ts +++ b/packages/frontend/src/scripts/code-highlighter.ts @@ -1,15 +1,21 @@ -import { bundledThemesInfo } from 'shiki'; +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + import { getHighlighterCore, loadWasm } from 'shiki/core'; import darkPlus from 'shiki/themes/dark-plus.mjs'; +import { bundledThemesInfo } from 'shiki/themes'; +import { bundledLanguagesInfo } from 'shiki/langs'; import { unique } from './array.js'; import { deepClone } from './clone.js'; import { deepMerge } from './merge.js'; -import type { Highlighter, LanguageRegistration, ThemeRegistration, ThemeRegistrationRaw } from 'shiki'; +import type { HighlighterCore, LanguageRegistration, ThemeRegistration, ThemeRegistrationRaw } from 'shiki/core'; import { ColdDeviceStorage } from '@/store.js'; import lightTheme from '@/themes/_light.json5'; import darkTheme from '@/themes/_dark.json5'; -let _highlighter: Highlighter | null = null; +let _highlighter: HighlighterCore | null = null; export async function getTheme(mode: 'light' | 'dark', getName: true): Promise<string>; export async function getTheme(mode: 'light' | 'dark', getName?: false): Promise<ThemeRegistration | ThemeRegistrationRaw>; @@ -46,16 +52,14 @@ export async function getTheme(mode: 'light' | 'dark', getName = false): Promise return darkPlus; } -export async function getHighlighter(): Promise<Highlighter> { +export async function getHighlighter(): Promise<HighlighterCore> { if (!_highlighter) { return await initHighlighter(); } return _highlighter; } -export async function initHighlighter() { - const aiScriptGrammar = await import('aiscript-vscode/aiscript/syntaxes/aiscript.tmLanguage.json'); - +async function initHighlighter() { await loadWasm(import('shiki/onig.wasm?init')); // テーマの重複を消す @@ -64,11 +68,12 @@ export async function initHighlighter() { ...(await Promise.all([getTheme('light'), getTheme('dark')])), ]); + const jsLangInfo = bundledLanguagesInfo.find(t => t.id === 'javascript'); const highlighter = await getHighlighterCore({ themes, langs: [ - import('shiki/langs/javascript.mjs'), - aiScriptGrammar.default as unknown as LanguageRegistration, + ...(jsLangInfo ? [async () => await jsLangInfo.import()] : []), + async () => (await import('aiscript-vscode/aiscript/syntaxes/aiscript.tmLanguage.json')).default as unknown as LanguageRegistration, ], }); diff --git a/packages/frontend/src/scripts/collapsed.ts b/packages/frontend/src/scripts/collapsed.ts index 237bd37c7a..4ec88a3c65 100644 --- a/packages/frontend/src/scripts/collapsed.ts +++ b/packages/frontend/src/scripts/collapsed.ts @@ -6,15 +6,16 @@ import * as Misskey from 'misskey-js'; export function shouldCollapsed(note: Misskey.entities.Note, urls: string[]): boolean { - const collapsed = note.cw == null && note.text != null && ( - (note.text.includes('$[x2')) || - (note.text.includes('$[x3')) || - (note.text.includes('$[x4')) || - (note.text.includes('$[scale')) || - (note.text.split('\n').length > 9) || - (note.text.length > 500) || - (note.files.length >= 5) || - (urls.length >= 4) + const collapsed = note.cw == null && ( + note.text != null && ( + (note.text.includes('$[x2')) || + (note.text.includes('$[x3')) || + (note.text.includes('$[x4')) || + (note.text.includes('$[scale')) || + (note.text.split('\n').length > 9) || + (note.text.length > 500) || + (urls.length >= 4) + ) || note.files.length >= 5 ); return collapsed; diff --git a/packages/frontend/src/scripts/favicon-dot.ts b/packages/frontend/src/scripts/favicon-dot.ts index e338f55f72..ba6dcb5d30 100644 --- a/packages/frontend/src/scripts/favicon-dot.ts +++ b/packages/frontend/src/scripts/favicon-dot.ts @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: leah and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + import tinycolor from 'tinycolor2'; class FavIconDot { @@ -17,13 +22,13 @@ class FavIconDot { */ public async setup() { const element: HTMLLinkElement = await this.getOrMakeFaviconElement(); - + this.faviconEL = element; this.src = this.faviconEL.getAttribute('href'); this.ctx = this.canvas.getContext('2d'); - + this.faviconImage = document.createElement('img'); - + this.hasLoaded = new Promise((resolve, reject) => { (this.faviconImage as HTMLImageElement).addEventListener('load', () => { this.canvas.width = (this.faviconImage as HTMLImageElement).width; @@ -100,7 +105,7 @@ export function setFavIconDot(visible: boolean) { icon = new FavIconDot(); await icon.setup(); } - + (icon as FavIconDot).setVisible(visible); }; diff --git a/packages/frontend/src/scripts/form.ts b/packages/frontend/src/scripts/form.ts index b0db404f28..242a504c3b 100644 --- a/packages/frontend/src/scripts/form.ts +++ b/packages/frontend/src/scripts/form.ts @@ -3,18 +3,22 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import * as Misskey from 'misskey-js'; + type EnumItem = string | { label: string; value: string; }; +type Hidden = boolean | ((v: any) => boolean); + export type FormItem = { label?: string; type: 'string'; default: string | null; description?: string; required?: boolean; - hidden?: boolean; + hidden?: Hidden; multiline?: boolean; treatAsMfm?: boolean; } | { @@ -23,27 +27,27 @@ export type FormItem = { default: number | null; description?: string; required?: boolean; - hidden?: boolean; + hidden?: Hidden; step?: number; } | { label?: string; type: 'boolean'; default: boolean | null; description?: string; - hidden?: boolean; + hidden?: Hidden; } | { label?: string; type: 'enum'; default: string | null; required?: boolean; - hidden?: boolean; + hidden?: Hidden; enum: EnumItem[]; } | { label?: string; type: 'radio'; default: unknown | null; required?: boolean; - hidden?: boolean; + hidden?: Hidden; options: { label: string; value: unknown; @@ -58,20 +62,27 @@ export type FormItem = { min: number; max: number; textConverter?: (value: number) => string; + hidden?: Hidden; } | { label?: string; type: 'object'; default: Record<string, unknown> | null; - hidden: boolean; + hidden: Hidden; } | { label?: string; type: 'array'; default: unknown[] | null; - hidden: boolean; + hidden: Hidden; } | { type: 'button'; content?: string; + hidden?: Hidden; action: (ev: MouseEvent, v: any) => void; +} | { + type: 'drive-file'; + defaultFileId?: string | null; + hidden?: Hidden; + validate?: (v: Misskey.entities.DriveFile) => Promise<boolean>; }; export type Form = Record<string, FormItem>; @@ -84,8 +95,9 @@ type GetItemType<Item extends FormItem> = Item['type'] extends 'range' ? number : Item['type'] extends 'enum' ? string : Item['type'] extends 'array' ? unknown[] : - Item['type'] extends 'object' ? Record<string, unknown> - : never; + Item['type'] extends 'object' ? Record<string, unknown> : + Item['type'] extends 'drive-file' ? Misskey.entities.DriveFile | undefined : + never; export type GetFormResultType<F extends Form> = { [P in keyof F]: GetItemType<F[P]>; diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/scripts/get-note-menu.ts index 40359a88bb..23c98c1881 100644 --- a/packages/frontend/src/scripts/get-note-menu.ts +++ b/packages/frontend/src/scripts/get-note-menu.ts @@ -16,7 +16,7 @@ import { url } from '@/config.js'; import { defaultStore, noteActions } from '@/store.js'; import { miLocalStorage } from '@/local-storage.js'; import { getUserMenu } from '@/scripts/get-user-menu.js'; -import { clipsCache } from '@/cache.js'; +import { clipsCache, favoritedChannelsCache } from '@/cache.js'; import { MenuItem } from '@/types/menu.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import { isSupportShare } from '@/scripts/navigator.js'; @@ -26,6 +26,14 @@ export async function getNoteClipMenu(props: { isDeleted: Ref<boolean>; currentClip?: Misskey.entities.Clip; }) { + function getClipName(clip: Misskey.entities.Clip) { + if ($i && clip.userId === $i.id && clip.notesCount != null) { + return `${clip.name} (${clip.notesCount}/${$i.policies.noteEachClipsLimit})`; + } else { + return clip.name; + } + } + const isRenote = ( props.note.renote != null && props.note.text == null && @@ -37,7 +45,7 @@ export async function getNoteClipMenu(props: { const clips = await clipsCache.fetch(); const menu: MenuItem[] = [...clips.map(clip => ({ - text: clip.name, + text: getClipName(clip), action: () => { claimAchievement('noteClipped1'); os.promiseDialog( @@ -50,7 +58,18 @@ export async function getNoteClipMenu(props: { text: i18n.tsx.confirmToUnclipAlreadyClippedNote({ name: clip.name }), }); if (!confirm.canceled) { - os.apiWithDialog('clips/remove-note', { clipId: clip.id, noteId: appearNote.id }); + os.apiWithDialog('clips/remove-note', { clipId: clip.id, noteId: appearNote.id }).then(() => { + clipsCache.set(clips.map(c => { + if (c.id === clip.id) { + return { + ...c, + notesCount: Math.max(0, ((c.notesCount ?? 0) - 1)), + }; + } else { + return c; + } + })); + }); if (props.currentClip?.id === clip.id) props.isDeleted.value = true; } } else { @@ -60,7 +79,18 @@ export async function getNoteClipMenu(props: { }); } }, - ); + ).then(() => { + clipsCache.set(clips.map(c => { + if (c.id === clip.id) { + return { + ...c, + notesCount: (c.notesCount ?? 0) + 1, + }; + } else { + return c; + } + })); + }); }, })), { type: 'divider' }, { icon: 'ph-plus ph-bold ph-lg', @@ -347,7 +377,7 @@ export function getNoteMenu(props: { action: () => toggleThreadMute(true), }), appearNote.userId === $i.id ? ($i.pinnedNoteIds ?? []).includes(appearNote.id) ? { - icon: 'ph-push-pin ph-bold ph-lgned-off', + icon: 'ph-push-pin-slash ph-bold ph-lg', text: i18n.ts.unpin, action: () => togglePin(false), } : { @@ -386,7 +416,7 @@ export function getNoteMenu(props: { { type: 'divider' }, { type: 'parent' as const, - icon: 'ti ti-device-tv', + icon: 'ph-television ph-bold ph-lg', text: i18n.ts.channel, children: async () => { const channelChildMenu = [] as MenuItem[]; @@ -395,7 +425,7 @@ export function getNoteMenu(props: { if (channel.pinnedNoteIds.includes(appearNote.id)) { channelChildMenu.push({ - icon: 'ti ti-pinned-off', + icon: 'ph-push-pin-slash ph-bold ph-lg', text: i18n.ts.unpin, action: () => os.apiWithDialog('channels/update', { channelId: appearNote.channel!.id, @@ -404,7 +434,7 @@ export function getNoteMenu(props: { }); } else { channelChildMenu.push({ - icon: 'ti ti-pin', + icon: 'ph-push-pin ph-bold ph-lg', text: i18n.ts.pin, action: () => os.apiWithDialog('channels/update', { channelId: appearNote.channel!.id, @@ -496,10 +526,9 @@ export function getNoteMenu(props: { }; } -type Visibility = 'public' | 'home' | 'followers' | 'specified'; +type Visibility = (typeof Misskey.noteVisibilities)[number]; -// defaultStore.state.visibilityがstringなためstringも受け付けている -function smallerVisibility(a: Visibility | string, b: Visibility | string): Visibility { +function smallerVisibility(a: Visibility, b: Visibility): Visibility { if (a === 'specified' || b === 'specified') return 'specified'; if (a === 'followers' || b === 'followers') return 'followers'; if (a === 'home' || b === 'home') return 'home'; @@ -523,11 +552,12 @@ export function getRenoteMenu(props: { const channelRenoteItems: MenuItem[] = []; const normalRenoteItems: MenuItem[] = []; + const normalExternalChannelRenoteItems: MenuItem[] = []; if (appearNote.channel) { channelRenoteItems.push(...[{ text: i18n.ts.inChannelRenote, - icon: 'ti ti-repeat', + icon: 'ph ph-repeat', action: () => { const el = props.renoteButton.value; if (el) { @@ -548,7 +578,7 @@ export function getRenoteMenu(props: { }, }, { text: i18n.ts.inChannelQuote, - icon: 'ti ti-quote', + icon: 'ph ph-quotes', action: () => { if (!props.mock) { os.post({ @@ -563,7 +593,7 @@ export function getRenoteMenu(props: { if (!appearNote.channel || appearNote.channel.allowRenoteToExternal) { normalRenoteItems.push(...[{ text: i18n.ts.renote, - icon: 'ti ti-repeat', + icon: 'ph ph-repeat', action: () => { const el = props.renoteButton.value; if (el) { @@ -594,19 +624,54 @@ export function getRenoteMenu(props: { }, }, (props.mock) ? undefined : { text: i18n.ts.quote, - icon: 'ti ti-quote', + icon: 'ph ph-quotes', action: () => { os.post({ renote: appearNote, }); }, }]); + + normalExternalChannelRenoteItems.push({ + type: 'parent', + icon: 'ph ph-repeat', + text: appearNote.channel ? i18n.ts.renoteToOtherChannel : i18n.ts.renoteToChannel, + children: async () => { + const channels = await favoritedChannelsCache.fetch(); + return channels.filter((channel) => { + if (!appearNote.channelId) return true; + return channel.id !== appearNote.channelId; + }).map((channel) => ({ + text: channel.name, + action: () => { + const el = props.renoteButton.value; + if (el) { + const rect = el.getBoundingClientRect(); + const x = rect.left + (el.offsetWidth / 2); + const y = rect.top + (el.offsetHeight / 2); + os.popup(MkRippleEffect, { x, y }, {}, 'end'); + } + + if (!props.mock) { + misskeyApi('notes/create', { + renoteId: appearNote.id, + channelId: channel.id, + }).then(() => { + os.toast(i18n.tsx.renotedToX({ name: channel.name })); + }); + } + }, + })); + }, + }); } const renoteItems = [ ...normalRenoteItems, ...(channelRenoteItems.length > 0 && normalRenoteItems.length > 0) ? [{ type: 'divider' }] as MenuItem[] : [], ...channelRenoteItems, + ...(normalExternalChannelRenoteItems.length > 0 && (normalRenoteItems.length > 0 || channelRenoteItems.length > 0)) ? [{ type: 'divider' }] as MenuItem[] : [], + ...normalExternalChannelRenoteItems, ]; return { diff --git a/packages/frontend/src/scripts/get-note-versions-menu.ts b/packages/frontend/src/scripts/get-note-versions-menu.ts index 84292f1277..9108191d73 100644 --- a/packages/frontend/src/scripts/get-note-versions-menu.ts +++ b/packages/frontend/src/scripts/get-note-versions-menu.ts @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: marie and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + import { Ref, defineAsyncComponent } from 'vue'; import * as Misskey from 'misskey-js'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/scripts/get-user-menu.ts b/packages/frontend/src/scripts/get-user-menu.ts index 61f9a453dc..3329a34ff3 100644 --- a/packages/frontend/src/scripts/get-user-menu.ts +++ b/packages/frontend/src/scripts/get-user-menu.ts @@ -272,7 +272,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter text: r.name, action: async () => { const { canceled, result: period } = await os.select({ - title: i18n.ts.period, + title: i18n.ts.period + ': ' + r.name, items: [{ value: 'indefinitely', text: i18n.ts.indefinitely, }, { diff --git a/packages/frontend/src/scripts/idb-proxy.ts b/packages/frontend/src/scripts/idb-proxy.ts index 1ca0990ba9..6b511f2a5f 100644 --- a/packages/frontend/src/scripts/idb-proxy.ts +++ b/packages/frontend/src/scripts/idb-proxy.ts @@ -15,6 +15,16 @@ const fallbackName = (key: string) => `idbfallback::${key}`; let idbAvailable = typeof window !== 'undefined' ? !!(window.indexedDB && window.indexedDB.open) : true; +// iframe.contentWindow.indexedDB.deleteDatabase() がchromeのバグで使用できないため、indexedDBを無効化している。 +// バグが治って再度有効化するのであれば、cypressのコマンド内のコメントアウトを外すこと +// see https://github.com/misskey-dev/misskey/issues/13605#issuecomment-2053652123 +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-expect-error +if (window.Cypress) { + idbAvailable = false; + console.log('Cypress detected. It will use localStorage.'); +} + if (idbAvailable) { await iset('idb-test', 'test') .catch(err => { diff --git a/packages/frontend/src/scripts/keycode.ts b/packages/frontend/src/scripts/keycode.ts index bc1f485f5e..7ffceafada 100644 --- a/packages/frontend/src/scripts/keycode.ts +++ b/packages/frontend/src/scripts/keycode.ts @@ -15,6 +15,7 @@ export default (input: string): string[] => { export const aliases = { 'esc': 'Escape', 'enter': ['Enter', 'NumpadEnter'], + 'space': [' ', 'Spacebar'], 'up': 'ArrowUp', 'down': 'ArrowDown', 'left': 'ArrowLeft', diff --git a/packages/frontend/src/scripts/media-has-audio.ts b/packages/frontend/src/scripts/media-has-audio.ts index 3421a38a76..4bf3ee5d97 100644 --- a/packages/frontend/src/scripts/media-has-audio.ts +++ b/packages/frontend/src/scripts/media-has-audio.ts @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + export default async function hasAudio(media: HTMLMediaElement) { const cloned = media.cloneNode() as HTMLMediaElement; cloned.muted = (cloned as typeof cloned & Partial<HTMLVideoElement>).playsInline = true; diff --git a/packages/frontend/src/scripts/popup-position.ts b/packages/frontend/src/scripts/popup-position.ts index 8c9e3c02c3..3dad41a8b3 100644 --- a/packages/frontend/src/scripts/popup-position.ts +++ b/packages/frontend/src/scripts/popup-position.ts @@ -26,8 +26,8 @@ export function calcPopupPosition(el: HTMLElement, props: { let top: number; if (props.anchorElement) { - left = rect.left + window.pageXOffset + (props.anchorElement.offsetWidth / 2); - top = (rect.top + window.pageYOffset - contentHeight) - props.innerMargin; + left = rect.left + window.scrollX + (props.anchorElement.offsetWidth / 2); + top = (rect.top + window.scrollY - contentHeight) - props.innerMargin; } else { left = props.x; top = (props.y - contentHeight) - props.innerMargin; @@ -35,8 +35,8 @@ export function calcPopupPosition(el: HTMLElement, props: { left -= (el.offsetWidth / 2); - if (left + contentWidth - window.pageXOffset > window.innerWidth) { - left = window.innerWidth - contentWidth + window.pageXOffset - 1; + if (left + contentWidth - window.scrollX > window.innerWidth) { + left = window.innerWidth - contentWidth + window.scrollX - 1; } return [left, top]; @@ -47,8 +47,8 @@ export function calcPopupPosition(el: HTMLElement, props: { let top: number; if (props.anchorElement) { - left = rect.left + window.pageXOffset + (props.anchorElement.offsetWidth / 2); - top = (rect.top + window.pageYOffset + props.anchorElement.offsetHeight) + props.innerMargin; + left = rect.left + window.scrollX + (props.anchorElement.offsetWidth / 2); + top = (rect.top + window.scrollY + props.anchorElement.offsetHeight) + props.innerMargin; } else { left = props.x; top = (props.y) + props.innerMargin; @@ -56,8 +56,8 @@ export function calcPopupPosition(el: HTMLElement, props: { left -= (el.offsetWidth / 2); - if (left + contentWidth - window.pageXOffset > window.innerWidth) { - left = window.innerWidth - contentWidth + window.pageXOffset - 1; + if (left + contentWidth - window.scrollX > window.innerWidth) { + left = window.innerWidth - contentWidth + window.scrollX - 1; } return [left, top]; @@ -68,8 +68,8 @@ export function calcPopupPosition(el: HTMLElement, props: { let top: number; if (props.anchorElement) { - left = (rect.left + window.pageXOffset - contentWidth) - props.innerMargin; - top = rect.top + window.pageYOffset + (props.anchorElement.offsetHeight / 2); + left = (rect.left + window.scrollX - contentWidth) - props.innerMargin; + top = rect.top + window.scrollY + (props.anchorElement.offsetHeight / 2); } else { left = (props.x - contentWidth) - props.innerMargin; top = props.y; @@ -77,8 +77,8 @@ export function calcPopupPosition(el: HTMLElement, props: { top -= (el.offsetHeight / 2); - if (top + contentHeight - window.pageYOffset > window.innerHeight) { - top = window.innerHeight - contentHeight + window.pageYOffset - 1; + if (top + contentHeight - window.scrollY > window.innerHeight) { + top = window.innerHeight - contentHeight + window.scrollY - 1; } return [left, top]; @@ -89,15 +89,15 @@ export function calcPopupPosition(el: HTMLElement, props: { let top: number; if (props.anchorElement) { - left = (rect.left + props.anchorElement.offsetWidth + window.pageXOffset) + props.innerMargin; + left = (rect.left + props.anchorElement.offsetWidth + window.scrollX) + props.innerMargin; if (props.align === 'top') { - top = rect.top + window.pageYOffset; + top = rect.top + window.scrollY; if (props.alignOffset != null) top += props.alignOffset; } else if (props.align === 'bottom') { // TODO } else { // center - top = rect.top + window.pageYOffset + (props.anchorElement.offsetHeight / 2); + top = rect.top + window.scrollY + (props.anchorElement.offsetHeight / 2); top -= (el.offsetHeight / 2); } } else { @@ -106,8 +106,8 @@ export function calcPopupPosition(el: HTMLElement, props: { top -= (el.offsetHeight / 2); } - if (top + contentHeight - window.pageYOffset > window.innerHeight) { - top = window.innerHeight - contentHeight + window.pageYOffset - 1; + if (top + contentHeight - window.scrollY > window.innerHeight) { + top = window.innerHeight - contentHeight + window.scrollY - 1; } return [left, top]; @@ -123,7 +123,7 @@ export function calcPopupPosition(el: HTMLElement, props: { const [left, top] = calcPosWhenTop(); // ツールチップを上に向かって表示するスペースがなければ下に向かって出す - if (top - window.pageYOffset < 0) { + if (top - window.scrollY < 0) { const [left, top] = calcPosWhenBottom(); return { left, top, transformOrigin: 'center top' }; } @@ -141,7 +141,7 @@ export function calcPopupPosition(el: HTMLElement, props: { const [left, top] = calcPosWhenLeft(); // ツールチップを左に向かって表示するスペースがなければ右に向かって出す - if (left - window.pageXOffset < 0) { + if (left - window.scrollX < 0) { const [left, top] = calcPosWhenRight(); return { left, top, transformOrigin: 'left center' }; } diff --git a/packages/frontend/src/scripts/search-engine-map.ts b/packages/frontend/src/scripts/search-engine-map.ts index 3fb06d135d..03e5061597 100644 --- a/packages/frontend/src/scripts/search-engine-map.ts +++ b/packages/frontend/src/scripts/search-engine-map.ts @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: leah and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + //store the URL and if its none of these its a custom one export const searchEngineMap = { //The first one is the default search engine diff --git a/packages/frontend/src/scripts/theme.ts b/packages/frontend/src/scripts/theme.ts index c49593ed42..e59643b09c 100644 --- a/packages/frontend/src/scripts/theme.ts +++ b/packages/frontend/src/scripts/theme.ts @@ -6,7 +6,7 @@ import { ref } from 'vue'; import tinycolor from 'tinycolor2'; import { deepClone } from './clone.js'; -import type { BuiltinTheme } from 'shiki'; +import type { BundledTheme } from 'shiki/themes'; import { globalEvents } from '@/events.js'; import lightTheme from '@/themes/_light.json5'; import darkTheme from '@/themes/_dark.json5'; @@ -20,7 +20,7 @@ export type Theme = { base?: 'dark' | 'light'; props: Record<string, string>; codeHighlighter?: { - base: BuiltinTheme; + base: BundledTheme; overrides?: Record<string, any>; } | { base: '_none_'; diff --git a/packages/frontend/src/scripts/upload.ts b/packages/frontend/src/scripts/upload.ts index 6c46b2bc1b..3e947183c9 100644 --- a/packages/frontend/src/scripts/upload.ts +++ b/packages/frontend/src/scripts/upload.ts @@ -5,6 +5,7 @@ import { reactive, ref } from 'vue'; import * as Misskey from 'misskey-js'; +import { v4 as uuid } from 'uuid'; import { readAndCompressImage } from '@misskey-dev/browser-image-resizer'; import { getCompressionConfig } from './upload/compress-config.js'; import { defaultStore } from '@/store.js'; @@ -39,13 +40,16 @@ export function uploadFile( if (folder && typeof folder === 'object') folder = folder.id; return new Promise((resolve, reject) => { - const id = Math.random().toString(); + const id = uuid(); const reader = new FileReader(); reader.onload = async (): Promise<void> => { + const filename = name ?? file.name ?? 'untitled'; + const extension = filename.split('.').length > 1 ? '.' + filename.split('.').pop() : ''; + const ctx = reactive<Uploading>({ - id: id, - name: name ?? file.name ?? 'untitled', + id, + name: defaultStore.state.keepOriginalFilename ? filename : id + extension, progressMax: undefined, progressValue: undefined, img: window.URL.createObjectURL(file), diff --git a/packages/frontend/src/scripts/use-chart-tooltip.ts b/packages/frontend/src/scripts/use-chart-tooltip.ts index 7e4bf5c9c6..bed221a622 100644 --- a/packages/frontend/src/scripts/use-chart-tooltip.ts +++ b/packages/frontend/src/scripts/use-chart-tooltip.ts @@ -53,11 +53,11 @@ export function useChartTooltip(opts: { position: 'top' | 'middle' } = { positio const rect = context.chart.canvas.getBoundingClientRect(); tooltipShowing.value = true; - tooltipX.value = rect.left + window.pageXOffset + context.tooltip.caretX; + tooltipX.value = rect.left + window.scrollX + context.tooltip.caretX; if (opts.position === 'top') { - tooltipY.value = rect.top + window.pageYOffset; + tooltipY.value = rect.top + window.scrollY; } else if (opts.position === 'middle') { - tooltipY.value = rect.top + window.pageYOffset + context.tooltip.caretY; + tooltipY.value = rect.top + window.scrollY + context.tooltip.caretY; } } diff --git a/packages/frontend/src/scripts/use-note-capture.ts b/packages/frontend/src/scripts/use-note-capture.ts index 3baa45d50f..1b3626bff5 100644 --- a/packages/frontend/src/scripts/use-note-capture.ts +++ b/packages/frontend/src/scripts/use-note-capture.ts @@ -54,6 +54,7 @@ export function useNoteCapture(props: { const currentCount = (note.value.reactions || {})[reaction] || 0; note.value.reactions[reaction] = currentCount + 1; + note.value.reactionCount += 1; if ($i && (body.userId === $i.id)) { note.value.myReaction = reaction; @@ -68,6 +69,7 @@ export function useNoteCapture(props: { const currentCount = (note.value.reactions || {})[reaction] || 0; note.value.reactions[reaction] = Math.max(0, currentCount - 1); + note.value.reactionCount = Math.max(0, note.value.reactionCount - 1); if (note.value.reactions[reaction] === 0) delete note.value.reactions[reaction]; if ($i && (body.userId === $i.id)) { diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index f78160e7ba..13f544e588 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -8,7 +8,6 @@ import * as Misskey from 'misskey-js'; import { miLocalStorage } from './local-storage.js'; import { searchEngineMap } from './scripts/search-engine-map.js'; import type { SoundType } from '@/scripts/sound.js'; -import type { BuiltinTheme as ShikiBuiltinTheme } from 'shiki'; import { Storage } from '@/pizzax.js'; import { hemisphere } from '@/scripts/intl-const.js'; @@ -112,7 +111,7 @@ export const defaultStore = markRaw(new Storage('base', { }, defaultNoteVisibility: { where: 'account', - default: 'public', + default: 'public' as (typeof Misskey.noteVisibilities)[number], }, defaultNoteLocalOnly: { where: 'account', @@ -184,7 +183,7 @@ export const defaultStore = markRaw(new Storage('base', { }, visibility: { where: 'deviceAccount', - default: 'public' as 'public' | 'home' | 'followers' | 'specified', + default: 'public' as (typeof Misskey.noteVisibilities)[number], }, localOnly: { where: 'deviceAccount', @@ -261,6 +260,10 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: true, }, + showReactionsCount: { + where: 'device', + default: false, + }, enableQuickAddMfmFunction: { where: 'device', default: false, @@ -505,6 +508,18 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: true, }, + useNativeUIForVideoAudioPlayer: { + where: 'device', + default: false, + }, + keepOriginalFilename: { + where: 'device', + default: true, + }, + alwaysConfirmFollow: { + where: 'device', + default: true, + }, sound_masterVolume: { where: 'device', diff --git a/packages/frontend/src/stream.ts b/packages/frontend/src/stream.ts index 0c5ee06197..0d5bd78b09 100644 --- a/packages/frontend/src/stream.ts +++ b/packages/frontend/src/stream.ts @@ -8,7 +8,12 @@ import { markRaw } from 'vue'; import { $i } from '@/account.js'; import { wsOrigin } from '@/config.js'; +// heart beat interval in ms +const HEART_BEAT_INTERVAL = 1000 * 60; + let stream: Misskey.Stream | null = null; +let timeoutHeartBeat: ReturnType<typeof setTimeout> | null = null; +let lastHeartbeatCall = 0; export function useStream(): Misskey.Stream { if (stream) return stream; @@ -17,7 +22,18 @@ export function useStream(): Misskey.Stream { token: $i.token, } : null)); - window.setTimeout(heartbeat, 1000 * 60); + if (timeoutHeartBeat) window.clearTimeout(timeoutHeartBeat); + timeoutHeartBeat = window.setTimeout(heartbeat, HEART_BEAT_INTERVAL); + + // send heartbeat right now when last send time is over HEART_BEAT_INTERVAL + document.addEventListener('visibilitychange', () => { + if ( + !stream + || document.visibilityState !== 'visible' + || Date.now() - lastHeartbeatCall < HEART_BEAT_INTERVAL + ) return; + heartbeat(); + }); return stream; } @@ -26,5 +42,7 @@ function heartbeat(): void { if (stream != null && document.visibilityState === 'visible') { stream.heartbeat(); } - window.setTimeout(heartbeat, 1000 * 60); + lastHeartbeatCall = Date.now(); + if (timeoutHeartBeat) window.clearTimeout(timeoutHeartBeat); + timeoutHeartBeat = window.setTimeout(heartbeat, HEART_BEAT_INTERVAL); } diff --git a/packages/frontend/src/style.scss b/packages/frontend/src/style.scss index 997e2051cc..37d1a3c557 100644 --- a/packages/frontend/src/style.scss +++ b/packages/frontend/src/style.scss @@ -41,6 +41,13 @@ --thread-width: 2px; //--ad: rgb(255 169 0 / 10%); + --eventFollow: #36aed2; + --eventRenote: #36d298; + --eventReply: #007aff; + --eventReactionHeart: #dd2e44; + --eventReaction: #e99a0b; + --eventAchievement: #cb9a11; + --eventOther: #88a6b7; } html.radius-misskey { @@ -458,12 +465,13 @@ rt { border-radius: 10px; --bg: #F1E8DC; - --panel: #fff; --fg: #693410; - --switchOffBg: rgba(0, 0, 0, 0.1); - --switchOffFg: rgb(255, 255, 255); - --switchOnBg: var(--accent); - --switchOnFg: rgb(255, 255, 255); +} + +html[data-color-mode=dark] ._woodenFrame { + --bg: #1d0c02; + --fg: #F1E8DC; + --panel: #192320; } ._woodenFrameH { diff --git a/packages/frontend/src/type.ts b/packages/frontend/src/type.ts index 9c0fc2a11e..5ff27158d2 100644 --- a/packages/frontend/src/type.ts +++ b/packages/frontend/src/type.ts @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + export type WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] }; export type WithNonNullable<T, K extends keyof T> = T & { [P in K]-?: NonNullable<T[P]> }; diff --git a/packages/frontend/src/types/menu.ts b/packages/frontend/src/types/menu.ts index 712f3464e5..138eb7dd62 100644 --- a/packages/frontend/src/types/menu.ts +++ b/packages/frontend/src/types/menu.ts @@ -6,6 +6,8 @@ import * as Misskey from 'misskey-js'; import { ComputedRef, Ref } from 'vue'; +interface MenuRadioOptionsDef extends Record<string, any> { } + export type MenuAction = (ev: MouseEvent) => void; export type MenuDivider = { type: 'divider' }; @@ -14,13 +16,15 @@ export type MenuLabel = { type: 'label', text: string }; export type MenuLink = { type: 'link', to: string, text: string, icon?: string, indicate?: boolean, avatar?: Misskey.entities.User }; export type MenuA = { type: 'a', href: string, target?: string, download?: string, text: string, icon?: string, indicate?: boolean }; export type MenuUser = { type: 'user', user: Misskey.entities.User, active?: boolean, indicate?: boolean, action: MenuAction }; -export type MenuSwitch = { type: 'switch', ref: Ref<boolean>, text: string, disabled?: boolean | Ref<boolean> }; +export type MenuSwitch = { type: 'switch', ref: Ref<boolean>, text: string, icon?: string, disabled?: boolean | Ref<boolean> }; export type MenuButton = { type?: 'button', text: string, icon?: string, indicate?: boolean, danger?: boolean, active?: boolean | ComputedRef<boolean>, avatar?: Misskey.entities.User; action: MenuAction }; +export type MenuRadio = { type: 'radio', text: string, icon?: string, ref: Ref<MenuRadioOptionsDef[keyof MenuRadioOptionsDef]>, options: MenuRadioOptionsDef, disabled?: boolean | Ref<boolean> }; +export type MenuRadioOption = { type: 'radioOption', text: string, action: MenuAction; active?: boolean | ComputedRef<boolean> }; export type MenuParent = { type: 'parent', text: string, icon?: string, children: MenuItem[] | (() => Promise<MenuItem[]> | MenuItem[]) }; export type MenuPending = { type: 'pending' }; -type OuterMenuItem = MenuDivider | MenuNull | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuParent; +type OuterMenuItem = MenuDivider | MenuNull | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuRadio | MenuRadioOption | MenuParent; type OuterPromiseMenuItem = Promise<MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuParent>; export type MenuItem = OuterMenuItem | OuterPromiseMenuItem; -export type InnerMenuItem = MenuDivider | MenuPending | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuParent; +export type InnerMenuItem = MenuDivider | MenuPending | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuRadio | MenuRadioOption | MenuParent; diff --git a/packages/frontend/src/ui/_common_/announcements.vue b/packages/frontend/src/ui/_common_/announcements.vue index b49eff9148..37d89f682f 100644 --- a/packages/frontend/src/ui/_common_/announcements.vue +++ b/packages/frontend/src/ui/_common_/announcements.vue @@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only v-for="announcement in $i.unreadAnnouncements.filter(x => x.display === 'banner')" :key="announcement.id" :class="$style.item" - to="/announcements" + :to="`/announcements/${announcement.id}`" > <span :class="$style.icon"> <i v-if="announcement.icon === 'info'" class="ph-info ph-bold ph-lg"></i> diff --git a/packages/frontend/src/ui/_common_/common.ts b/packages/frontend/src/ui/_common_/common.ts index 3d5b42241e..f1c23860b6 100644 --- a/packages/frontend/src/ui/_common_/common.ts +++ b/packages/frontend/src/ui/_common_/common.ts @@ -79,7 +79,12 @@ export function openInstanceMenu(ev: MouseEvent) { text: i18n.ts.tools, icon: 'ph-toolbox ph-bold ph-lg', children: toolsMenuItems(), - }, { type: 'divider' }, (instance.impressumUrl) ? { + }, { type: 'divider' }, { + type: 'link', + text: i18n.ts.inquiry, + icon: 'ph-question ph-bold ph-lg', + to: '/contact', + }, (instance.impressumUrl) ? { text: i18n.ts.impressum, icon: 'ph-newspaper-clipping ph-bold ph-lg', action: () => { @@ -104,8 +109,8 @@ export function openInstanceMenu(ev: MouseEvent) { window.open(instance.donationUrl, '_blank', 'noopener'); }, } : undefined, (!instance.impressumUrl && !instance.tosUrl && !instance.privacyPolicyUrl && !instance.donationUrl) ? undefined : { type: 'divider' }, { - text: i18n.ts.help, - icon: 'ph-question ph-bold ph-lg', + text: i18n.ts.document, + icon: 'ph-libghtbulb ph-bold ph-lg', action: () => { window.open('https://misskey-hub.net/docs/for-users/', '_blank', 'noopener'); }, diff --git a/packages/frontend/src/ui/_common_/statusbar-rss.vue b/packages/frontend/src/ui/_common_/statusbar-rss.vue index b973a4fd6b..6e1d06eec1 100644 --- a/packages/frontend/src/ui/_common_/statusbar-rss.vue +++ b/packages/frontend/src/ui/_common_/statusbar-rss.vue @@ -28,6 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; +import * as Misskey from 'misskey-js'; import MarqueeText from '@/components/MkMarquee.vue'; import { useInterval } from '@/scripts/use-interval.js'; import { shuffle } from '@/scripts/shuffle.js'; @@ -42,13 +43,13 @@ const props = defineProps<{ refreshIntervalSec?: number; }>(); -const items = ref([]); +const items = ref<Misskey.entities.FetchRssResponse['items']>([]); const fetching = ref(true); const key = ref(0); const tick = () => { window.fetch(`/api/fetch-rss?url=${props.url}`, {}).then(res => { - res.json().then(feed => { + res.json().then((feed: Misskey.entities.FetchRssResponse) => { if (props.shuffle) { shuffle(feed.items); } diff --git a/packages/frontend/src/ui/deck/antenna-column.vue b/packages/frontend/src/ui/deck/antenna-column.vue index 79c7c48073..ca7d7102a3 100644 --- a/packages/frontend/src/ui/deck/antenna-column.vue +++ b/packages/frontend/src/ui/deck/antenna-column.vue @@ -9,18 +9,22 @@ SPDX-License-Identifier: AGPL-3.0-only <i class="ph-flying-saucer ph-bold ph-lg"></i><span style="margin-left: 8px;">{{ column.name }}</span> </template> - <MkTimeline v-if="column.antennaId" ref="timeline" src="antenna" :antenna="column.antennaId"/> + <MkTimeline v-if="column.antennaId" ref="timeline" src="antenna" :antenna="column.antennaId" @note="onNote"/> </XColumn> </template> <script lang="ts" setup> -import { onMounted, shallowRef } from 'vue'; +import { onMounted, ref, shallowRef, watch } from 'vue'; import XColumn from './column.vue'; import { updateColumn, Column } from './deck-store.js'; import MkTimeline from '@/components/MkTimeline.vue'; import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; +import { MenuItem } from '@/types/menu.js'; +import { SoundStore } from '@/store.js'; +import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js'; +import * as sound from '@/scripts/sound.js'; const props = defineProps<{ column: Column; @@ -28,6 +32,7 @@ const props = defineProps<{ }>(); const timeline = shallowRef<InstanceType<typeof MkTimeline>>(); +const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 }); onMounted(() => { if (props.column.antennaId == null) { @@ -35,6 +40,10 @@ onMounted(() => { } }); +watch(soundSetting, v => { + updateColumn(props.column.id, { soundSetting: v }); +}); + async function setAntenna() { const antennas = await misskeyApi('antennas/list'); const { canceled, result: antenna } = await os.select({ @@ -54,7 +63,11 @@ function editAntenna() { os.pageWindow('my/antennas/' + props.column.antennaId); } -const menu = [ +function onNote() { + sound.playMisskeySfxFile(soundSetting.value); +} + +const menu: MenuItem[] = [ { icon: 'ph-pencil-simple ph-bold ph-lg', text: i18n.ts.selectAntenna, @@ -65,6 +78,11 @@ const menu = [ text: i18n.ts.editAntenna, action: editAntenna, }, + { + icon: 'ph-bell-ringing ph-bold ph-lg', + text: i18n.ts._deck.newNoteNotificationSettings, + action: () => soundSettingsButton(soundSetting), + }, ]; /* diff --git a/packages/frontend/src/ui/deck/channel-column.vue b/packages/frontend/src/ui/deck/channel-column.vue index 993be46910..10b3d5a1ec 100644 --- a/packages/frontend/src/ui/deck/channel-column.vue +++ b/packages/frontend/src/ui/deck/channel-column.vue @@ -13,21 +13,26 @@ SPDX-License-Identifier: AGPL-3.0-only <div style="padding: 8px; text-align: center;"> <MkButton primary gradate rounded inline small @click="post"><i class="ph-pencil-simple ph-bold ph-lg"></i></MkButton> </div> - <MkTimeline ref="timeline" src="channel" :channel="column.channelId" :key="column.channelId + column.withRenotes + column.onlyFiles" :withRenotes="withRenotes" :onlyFiles="onlyFiles"/> + <MkTimeline ref="timeline" :key="column.channelId + column.withRenotes + column.onlyFiles" src="channel" :channel="column.channelId" :withRenotes="withRenotes" :onlyFiles="onlyFiles" @note="onNote"/> </template> </XColumn> </template> <script lang="ts" setup> -import { watch, ref, shallowRef } from 'vue'; +import { ref, shallowRef, watch } from 'vue'; import * as Misskey from 'misskey-js'; import XColumn from './column.vue'; import { updateColumn, Column } from './deck-store.js'; import MkTimeline from '@/components/MkTimeline.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; +import { favoritedChannelsCache } from '@/cache.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; +import { MenuItem } from '@/types/menu.js'; +import { SoundStore } from '@/store.js'; +import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js'; +import * as sound from '@/scripts/sound.js'; const props = defineProps<{ column: Column; @@ -51,25 +56,29 @@ watch(onlyFiles, v => { }); }); +const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 }); + if (props.column.channelId == null) { setChannel(); } +watch(soundSetting, v => { + updateColumn(props.column.id, { soundSetting: v }); +}); + async function setChannel() { - const channels = await misskeyApi('channels/my-favorites', { - limit: 100, - }); - const { canceled, result: channel } = await os.select({ + const channels = await favoritedChannelsCache.fetch(); + const { canceled, result: chosenChannel } = await os.select({ title: i18n.ts.selectChannel, items: channels.map(x => ({ value: x, text: x.name, })), default: props.column.channelId, }); - if (canceled) return; + if (canceled || chosenChannel == null) return; updateColumn(props.column.id, { - channelId: channel.id, - name: channel.name, + channelId: chosenChannel.id, + name: chosenChannel.name, }); } @@ -85,7 +94,11 @@ async function post() { }); } -const menu = [{ +function onNote() { + sound.playMisskeySfxFile(soundSetting.value); +} + +const menu: MenuItem[] = [{ icon: 'ph-pencil-simple ph-bold ph-lg', text: i18n.ts.selectChannel, action: setChannel, @@ -97,5 +110,9 @@ const menu = [{ type: 'switch', text: i18n.ts.fileAttachedOnly, ref: onlyFiles, +}, { + icon: 'ph-bell-ringing ph-bold ph-lg', + text: i18n.ts._deck.newNoteNotificationSettings, + action: () => soundSettingsButton(soundSetting), }]; </script> diff --git a/packages/frontend/src/ui/deck/deck-store.ts b/packages/frontend/src/ui/deck/deck-store.ts index 6c4e2fd52b..1a4f7c5e17 100644 --- a/packages/frontend/src/ui/deck/deck-store.ts +++ b/packages/frontend/src/ui/deck/deck-store.ts @@ -9,6 +9,7 @@ import { notificationTypes } from 'misskey-js'; import { Storage } from '@/pizzax.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { deepClone } from '@/scripts/clone.js'; +import { SoundStore } from '@/store.js'; type ColumnWidget = { name: string; @@ -33,6 +34,7 @@ export type Column = { withRenotes?: boolean; withReplies?: boolean; onlyFiles?: boolean; + soundSetting: SoundStore; }; export const deckStore = markRaw(new Storage('deck', { diff --git a/packages/frontend/src/ui/deck/list-column.vue b/packages/frontend/src/ui/deck/list-column.vue index f7988ed1b7..eb8e657e98 100644 --- a/packages/frontend/src/ui/deck/list-column.vue +++ b/packages/frontend/src/ui/deck/list-column.vue @@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only <i class="ph-list ph-bold ph-lg"></i><span style="margin-left: 8px;">{{ column.name }}</span> </template> - <MkTimeline v-if="column.listId" ref="timeline" src="list" :list="column.listId" :key="column.listId + column.withRenotes + column.onlyFiles" :withRenotes="withRenotes" :onlyFiles="onlyFiles"/> + <MkTimeline v-if="column.listId" ref="timeline" :key="column.listId + column.withRenotes + column.onlyFiles" src="list" :list="column.listId" :withRenotes="withRenotes" :onlyFiles="onlyFiles" @note="onNote"/> </XColumn> </template> @@ -21,6 +21,10 @@ import MkTimeline from '@/components/MkTimeline.vue'; import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; +import { MenuItem } from '@/types/menu.js'; +import { SoundStore } from '@/store.js'; +import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js'; +import * as sound from '@/scripts/sound.js'; const props = defineProps<{ column: Column; @@ -30,6 +34,7 @@ const props = defineProps<{ const timeline = shallowRef<InstanceType<typeof MkTimeline>>(); const withRenotes = ref(props.column.withRenotes ?? true); const onlyFiles = ref(props.column.onlyFiles ?? false); +const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 }); if (props.column.listId == null) { setList(); @@ -47,6 +52,10 @@ watch(onlyFiles, v => { }); }); +watch(soundSetting, v => { + updateColumn(props.column.id, { soundSetting: v }); +}); + async function setList() { const lists = await misskeyApi('users/lists/list'); const { canceled, result: list } = await os.select({ @@ -66,7 +75,11 @@ function editList() { os.pageWindow('my/lists/' + props.column.listId); } -const menu = [ +function onNote() { + sound.playMisskeySfxFile(soundSetting.value); +} + +const menu: MenuItem[] = [ { icon: 'ph-pencil-simple ph-bold ph-lg', text: i18n.ts.selectList, @@ -87,5 +100,10 @@ const menu = [ text: i18n.ts.fileAttachedOnly, ref: onlyFiles, }, + { + icon: 'ph-bell-ringing ph-bold ph-lg', + text: i18n.ts._deck.newNoteNotificationSettings, + action: () => soundSettingsButton(soundSetting), + }, ]; </script> diff --git a/packages/frontend/src/ui/deck/role-timeline-column.vue b/packages/frontend/src/ui/deck/role-timeline-column.vue index 1a673a1753..09a6438afd 100644 --- a/packages/frontend/src/ui/deck/role-timeline-column.vue +++ b/packages/frontend/src/ui/deck/role-timeline-column.vue @@ -9,18 +9,22 @@ SPDX-License-Identifier: AGPL-3.0-only <i class="ph-seal-check ph-bold ph-lg"></i><span style="margin-left: 8px;">{{ column.name }}</span> </template> - <MkTimeline v-if="column.roleId" ref="timeline" src="role" :role="column.roleId"/> + <MkTimeline v-if="column.roleId" ref="timeline" src="role" :role="column.roleId" @note="onNote"/> </XColumn> </template> <script lang="ts" setup> -import { onMounted, shallowRef } from 'vue'; +import { onMounted, ref, shallowRef, watch } from 'vue'; import XColumn from './column.vue'; import { updateColumn, Column } from './deck-store.js'; import MkTimeline from '@/components/MkTimeline.vue'; import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; +import { MenuItem } from '@/types/menu.js'; +import { SoundStore } from '@/store.js'; +import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js'; +import * as sound from '@/scripts/sound.js'; const props = defineProps<{ column: Column; @@ -28,6 +32,7 @@ const props = defineProps<{ }>(); const timeline = shallowRef<InstanceType<typeof MkTimeline>>(); +const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 }); onMounted(() => { if (props.column.roleId == null) { @@ -35,6 +40,10 @@ onMounted(() => { } }); +watch(soundSetting, v => { + updateColumn(props.column.id, { soundSetting: v }); +}); + async function setRole() { const roles = (await misskeyApi('roles/list')).filter(x => x.isExplorable); const { canceled, result: role } = await os.select({ @@ -50,10 +59,18 @@ async function setRole() { }); } -const menu = [{ +function onNote() { + sound.playMisskeySfxFile(soundSetting.value); +} + +const menu: MenuItem[] = [{ icon: 'ph-pencil-simple ph-bold ph-lg', text: i18n.ts.role, action: setRole, +}, { + icon: 'ph-bell-ringing ph-bold ph-lg', + text: i18n.ts._deck.newNoteNotificationSettings, + action: () => soundSettingsButton(soundSetting), }]; /* diff --git a/packages/frontend/src/ui/deck/tl-column.vue b/packages/frontend/src/ui/deck/tl-column.vue index 3745d026e8..0c0a435403 100644 --- a/packages/frontend/src/ui/deck/tl-column.vue +++ b/packages/frontend/src/ui/deck/tl-column.vue @@ -29,6 +29,7 @@ SPDX-License-Identifier: AGPL-3.0-only :withRenotes="withRenotes" :withReplies="withReplies" :onlyFiles="onlyFiles" + @note="onNote" /> </XColumn> </template> @@ -42,6 +43,10 @@ import * as os from '@/os.js'; import { $i } from '@/account.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; +import { MenuItem } from '@/types/menu.js'; +import { SoundStore } from '@/store.js'; +import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js'; +import * as sound from '@/scripts/sound.js'; const props = defineProps<{ column: Column; @@ -54,6 +59,7 @@ const timeline = shallowRef<InstanceType<typeof MkTimeline>>(); const isLocalTimelineAvailable = (($i == null && instance.policies.ltlAvailable) || ($i != null && $i.policies.ltlAvailable)); const isGlobalTimelineAvailable = (($i == null && instance.policies.gtlAvailable) || ($i != null && $i.policies.gtlAvailable)); const isBubbleTimelineAvailable = ($i == null && instance.policies.btlAvailable) || ($i != null && $i.policies.btlAvailable); +const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 }); const withRenotes = ref(props.column.withRenotes ?? true); const withReplies = ref(props.column.withReplies ?? false); const onlyFiles = ref(props.column.onlyFiles ?? false); @@ -76,6 +82,10 @@ watch(onlyFiles, v => { }); }); +watch(soundSetting, v => { + updateColumn(props.column.id, { soundSetting: v }); +}); + onMounted(() => { if (props.column.tl == null) { setType(); @@ -113,11 +123,19 @@ async function setType() { }); } -const menu = [{ +function onNote() { + sound.playMisskeySfxFile(soundSetting.value); +} + +const menu: MenuItem[] = [{ icon: 'ph-pencil-simple ph-bold ph-lg', text: i18n.ts.timeline, action: setType, }, { + icon: 'ph-bell-ringing ph-bold ph-lg', + text: i18n.ts._deck.newNoteNotificationSettings, + action: () => soundSettingsButton(soundSetting), +}, { type: 'switch', text: i18n.ts.showRenotes, ref: withRenotes, diff --git a/packages/frontend/src/ui/deck/tl-note-notification.ts b/packages/frontend/src/ui/deck/tl-note-notification.ts new file mode 100644 index 0000000000..275ea56ba0 --- /dev/null +++ b/packages/frontend/src/ui/deck/tl-note-notification.ts @@ -0,0 +1,107 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Misskey from 'misskey-js'; +import { Ref } from 'vue'; +import { SoundStore } from '@/store.js'; +import { getSoundDuration, playMisskeySfxFile, soundsTypes, SoundType } from '@/scripts/sound.js'; +import { i18n } from '@/i18n.js'; +import * as os from '@/os.js'; + +export async function soundSettingsButton(soundSetting: Ref<SoundStore>): Promise<void> { + function getSoundTypeName(f: SoundType): string { + switch (f) { + case null: + return i18n.ts.none; + case '_driveFile_': + return i18n.ts._soundSettings.driveFile; + default: + return f; + } + } + + const { canceled, result } = await os.form(i18n.ts.sound, { + type: { + type: 'enum', + label: i18n.ts.sound, + default: soundSetting.value.type ?? 'none', + enum: soundsTypes.map(f => ({ + value: f ?? 'none', label: getSoundTypeName(f), + })), + }, + soundFile: { + type: 'drive-file', + label: i18n.ts.file, + defaultFileId: soundSetting.value.type === '_driveFile_' ? soundSetting.value.fileId : null, + hidden: v => v.type !== '_driveFile_', + validate: async (file: Misskey.entities.DriveFile) => { + if (!file.type.startsWith('audio')) { + os.alert({ + type: 'warning', + title: i18n.ts._soundSettings.driveFileTypeWarn, + text: i18n.ts._soundSettings.driveFileTypeWarnDescription, + }); + return false; + } + + const duration = await getSoundDuration(file.url); + if (duration >= 2000) { + const { canceled } = await os.confirm({ + type: 'warning', + title: i18n.ts._soundSettings.driveFileDurationWarn, + text: i18n.ts._soundSettings.driveFileDurationWarnDescription, + okText: i18n.ts.continue, + cancelText: i18n.ts.cancel, + }); + if (canceled) return false; + } + + return true; + }, + }, + volume: { + type: 'range', + label: i18n.ts.volume, + default: soundSetting.value.volume ?? 1, + textConverter: (v) => `${Math.floor(v * 100)}%`, + min: 0, + max: 1, + step: 0.05, + }, + listen: { + type: 'button', + content: i18n.ts.listen, + action: (_, v) => { + const sound = buildSoundStore(v); + if (!sound) return; + playMisskeySfxFile(sound); + }, + }, + }); + if (canceled) return; + + const res = buildSoundStore(result); + if (res) soundSetting.value = res; + + function buildSoundStore(result: any): SoundStore | null { + const type = (result.type === 'none' ? null : result.type) as SoundType; + const volume = result.volume as number; + const fileId = result.soundFile?.id ?? (soundSetting.value.type === '_driveFile_' ? soundSetting.value.fileId : undefined); + const fileUrl = result.soundFile?.url ?? (soundSetting.value.type === '_driveFile_' ? soundSetting.value.fileUrl : undefined); + + if (type === '_driveFile_') { + if (!fileUrl || !fileId) { + os.alert({ + type: 'warning', + text: i18n.ts._soundSettings.driveFileWarn, + }); + return null; + } + return { type, volume, fileId, fileUrl }; + } else { + return { type, volume }; + } + } +} diff --git a/packages/frontend/src/ui/visitor.vue b/packages/frontend/src/ui/visitor.vue index f76372ae34..0c818ba21e 100644 --- a/packages/frontend/src/ui/visitor.vue +++ b/packages/frontend/src/ui/visitor.vue @@ -68,11 +68,9 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onMounted, provide, ref, computed } from 'vue'; -import * as Misskey from 'misskey-js'; import XCommon from './_common_/common.vue'; import { instanceName } from '@/config.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; import { instance } from '@/instance.js'; import XSigninDialog from '@/components/MkSigninDialog.vue'; import XSignupDialog from '@/components/MkSignupDialog.vue'; @@ -112,7 +110,6 @@ const isTimelineAvailable = ref(instance.policies?.ltlAvailable || instance.poli const showMenu = ref(false); const isDesktop = ref(window.innerWidth >= DESKTOP_THRESHOLD); const narrow = ref(window.innerWidth < 1280); -const meta = ref<Misskey.entities.MetaResponse>(); const keymap = computed(() => { return { @@ -126,10 +123,6 @@ const keymap = computed(() => { }; }); -misskeyApi('meta', { detail: true }).then(res => { - meta.value = res; -}); - function signin() { os.popup(XSigninDialog, { autoSet: true, diff --git a/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue b/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue index 36ba9f8255..e22697ca68 100644 --- a/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue +++ b/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue @@ -7,6 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkContainer :showHeader="widgetProps.showHeader" class="mkw-bdayfollowings"> <template #icon><i class="ph-cake ph-bold ph-lg"></i></template> <template #header>{{ i18n.ts._widgets.birthdayFollowings }}</template> + <template #func="{ buttonStyleClass }"><button class="_button" :class="buttonStyleClass" @click="actualFetch()"><i class="ph-arrows-clockwise ph-bold ph-lg"></i></button></template> <div :class="$style.bdayFRoot"> <MkLoading v-if="fetching"/> @@ -53,7 +54,7 @@ const { widgetProps, configure } = useWidgetPropsManager(name, emit, ); -const users = ref<Misskey.entities.FollowingFolloweePopulated[]>([]); +const users = ref<Misskey.Endpoints['users/following']['res']>([]); const fetching = ref(true); let lastFetchedAt = '1970-01-01'; @@ -70,19 +71,35 @@ const fetch = () => { now.setHours(0, 0, 0, 0); if (now > lfAtD) { - misskeyApi('users/following', { - limit: 18, - birthday: now.toISOString(), - userId: $i.id, - }).then(res => { - users.value = res; - fetching.value = false; - }); + actualFetch(); lastFetchedAt = now.toISOString(); } }; +function actualFetch() { + if ($i == null) { + users.value = []; + fetching.value = false; + return; + } + + const now = new Date(); + now.setHours(0, 0, 0, 0); + fetching.value = true; + misskeyApi('users/following', { + limit: 18, + birthday: `${now.getFullYear().toString().padStart(4, '0')}-${(now.getMonth() + 1).toString().padStart(2, '0')}-${now.getDate().toString().padStart(2, '0')}`, + userId: $i.id, + }).then(res => { + users.value = res; + window.setTimeout(() => { + // 早すぎるとチカチカする + fetching.value = false; + }, 100); + }); +} + useInterval(fetch, 1000 * 60, { immediate: true, afterMounted: true, diff --git a/packages/frontend/src/widgets/WidgetRss.vue b/packages/frontend/src/widgets/WidgetRss.vue index e0272bc7d7..aa4bf155de 100644 --- a/packages/frontend/src/widgets/WidgetRss.vue +++ b/packages/frontend/src/widgets/WidgetRss.vue @@ -24,6 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, watch, computed } from 'vue'; +import * as Misskey from 'misskey-js'; import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; import { GetFormResultType } from '@/scripts/form.js'; import MkContainer from '@/components/MkContainer.vue'; @@ -64,7 +65,7 @@ const { widgetProps, configure } = useWidgetPropsManager(name, emit, ); -const rawItems = ref([]); +const rawItems = ref<Misskey.entities.FetchRssResponse['items']>([]); const items = computed(() => rawItems.value.slice(0, widgetProps.maxEntries)); const fetching = ref(true); const fetchEndpoint = computed(() => { @@ -79,8 +80,8 @@ const tick = () => { window.fetch(fetchEndpoint.value, {}) .then(res => res.json()) - .then(feed => { - rawItems.value = feed.items ?? []; + .then((feed: Misskey.entities.FetchRssResponse) => { + rawItems.value = feed.items; fetching.value = false; }); }; diff --git a/packages/frontend/src/widgets/WidgetRssTicker.vue b/packages/frontend/src/widgets/WidgetRssTicker.vue index 7456f9d35f..f1dde14bf6 100644 --- a/packages/frontend/src/widgets/WidgetRssTicker.vue +++ b/packages/frontend/src/widgets/WidgetRssTicker.vue @@ -28,6 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, watch, computed } from 'vue'; +import * as Misskey from 'misskey-js'; import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; import MarqueeText from '@/components/MkMarquee.vue'; import { GetFormResultType } from '@/scripts/form.js'; @@ -87,7 +88,7 @@ const { widgetProps, configure } = useWidgetPropsManager(name, emit, ); -const rawItems = ref([]); +const rawItems = ref<Misskey.entities.FetchRssResponse['items']>([]); const items = computed(() => { const newItems = rawItems.value.slice(0, widgetProps.maxEntries); if (widgetProps.shuffle) { @@ -110,8 +111,8 @@ const tick = () => { window.fetch(fetchEndpoint.value, {}) .then(res => res.json()) - .then(feed => { - rawItems.value = feed.items ?? []; + .then((feed: Misskey.entities.FetchRssResponse) => { + rawItems.value = feed.items; fetching.value = false; key.value++; }); diff --git a/packages/frontend/src/widgets/WidgetSearch.vue b/packages/frontend/src/widgets/WidgetSearch.vue index cf91a8f089..aeb1bc6ee0 100644 --- a/packages/frontend/src/widgets/WidgetSearch.vue +++ b/packages/frontend/src/widgets/WidgetSearch.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: marie and other Sharkey contributors SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/widgets/WidgetUnixClock.vue b/packages/frontend/src/widgets/WidgetUnixClock.vue index 2ac7d1c781..832cd575cc 100644 --- a/packages/frontend/src/widgets/WidgetUnixClock.vue +++ b/packages/frontend/src/widgets/WidgetUnixClock.vue @@ -68,9 +68,9 @@ watch(showColon, (v) => { }); const tick = () => { - const now = new Date(); - ss.value = Math.floor(now.getTime() / 1000).toString(); - ms.value = Math.floor(now.getTime() % 1000 / 10).toString().padStart(2, '0'); + const now = Date.now(); + ss.value = Math.floor(now / 1000).toString(); + ms.value = Math.floor(now % 1000 / 10).toString().padStart(2, '0'); if (ss.value !== prevSec) showColon.value = true; prevSec = ss.value; }; |