diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2023-02-26 20:21:54 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2023-02-26 20:21:54 +0900 |
| commit | 02c8fd9de51b6a8471ab9a89f23bcfeaecd7626c (patch) | |
| tree | 018a46cad9a19cc8cdfcff91442b343a637b56f8 /packages/frontend/src | |
| parent | Merge pull request #10058 from misskey-dev/develop (diff) | |
| parent | Merge branch 'develop' of https://github.com/misskey-dev/misskey into develop (diff) | |
| download | misskey-02c8fd9de51b6a8471ab9a89f23bcfeaecd7626c.tar.gz misskey-02c8fd9de51b6a8471ab9a89f23bcfeaecd7626c.tar.bz2 misskey-02c8fd9de51b6a8471ab9a89f23bcfeaecd7626c.zip | |
Merge pull request #10108 from misskey-dev/develop
* Add dialog to remove follower (#9718)
* update PULL_REQUEST_TEMPLATE
* 起動時にRedisの疎通確認を行う (#9832)
* 起動時にRedisの疎通確認を行う
* check:connectをstart内に移動
---------
Co-authored-by: tamaina <tamaina@hotmail.co.jp>
* Pass `--detectOpenHandles` to Jest (#9895)
Co-authored-by: tamaina <tamaina@hotmail.co.jp>
* enhance(client): MkUrlPreviewの閉じるボタンを見やすく (#9913)
Co-authored-by: tamaina <tamaina@hotmail.co.jp>
* test(backend): restore ap-request tests (#9997)
Co-authored-by: tamaina <tamaina@hotmail.co.jp>
* fix/refaftor(client): MkTime.vueの変更 (#10061)
* fix(client): MkTime.timeにstringでもDateでない値が入った場合、?を表示
* fix(client): MkTimeを改良
* numberを許容
* falsyな値もとる
* 不明
* ありません
* fix
* fix(server): notes/createで、fileIdsと見つかったファイルの数が異なる場合はエラーにする (#9911)
* fix(server): notes/createで、fileIdsと見つかったファイルの数が異なる場合はエラーにする
* NO_SUCH_FILE
* Update codecov.yml
* Update apple-touch-icon.png
* デプロイされているプレビュー環境がない場合はプレビュー環境を削除しないようにする (#10062)
* デプロイされているプレビュー環境がない場合はDestroy preview environmentを実行しないようにする
* CIがない場合の処理追加
* enhance(client): improve clip menu ux
* 未知のユーザーが deleteActor されたら処理をスキップする (#10067)
* fix(client): Android ChromeでPWAとしてインストールできない問題を修正 (#10069)
* fix(client): Android ChromeでPWAとしてインストールできない問題を修正
* 順番関係ある?
* Windows環境でswcを使うと正常にビルドができない問題の修正 (#10074)
* Update @swc/core to v1.3.36
* Update CHANGELOG.md
* Update CHANGELOG.md
* バックグラウンドで一定時間経過したらページネーションのアイテム更新をしない (#10053)
* :art:
* feat: 2つの検索画面の統合 (#9949) (#10038)
* feat: 検索画面の UI を統一
* fix: エラーの修正
* add: changelog
---------
Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
* enhance(client): ノートメニューからユーザーメニューを開けるように
Resolve #10019
* enhance(client): renoteした際の表示を改善
Resolve #10078
* Update CHANGELOG.md
* enhance(client): tweak contextmenu position calculation
* :art:
* :art:
* feat: in-channel featured note
Resolve #9938
* refactor(frontend): fix eslint error (#10084)
* Simplify search.vue (remove dead code) (#10088)
* Simplify search.vue
This is already handled by the code above it, no need to handle it twice
* Remove unused imports
* Update about-misskey.vue
* test(server): add validation test of api:notes/create (#10090)
* fix(server): notes/createのバリデーションが効いていない
Fix #10079
Co-Authored-By: mei23 <m@m544.net>
* anyOf内にバリデーションを書いても最初の一つしかチェックされない
* :v:
* wip
* wip
* :v:
* RequiredProp
* Revert "RequiredProp"
This reverts commit 74693900119a590263106fa3adefd008d69ce80c.
* add api:notes/create
* fix lint
* text
* :v:
* improve readability
---------
Co-authored-by: mei23 <m@m544.net>
Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
* New Crowdin updates (#10059)
* New translations ja-JP.yml (Japanese, Kansai)
* New translations ja-JP.yml (Romanian)
* New translations ja-JP.yml (French)
* New translations ja-JP.yml (Spanish)
* New translations ja-JP.yml (Arabic)
* New translations ja-JP.yml (Czech)
* New translations ja-JP.yml (German)
* New translations ja-JP.yml (Italian)
* New translations ja-JP.yml (Korean)
* New translations ja-JP.yml (Polish)
* New translations ja-JP.yml (Russian)
* New translations ja-JP.yml (Slovak)
* New translations ja-JP.yml (Ukrainian)
* New translations ja-JP.yml (Chinese Simplified)
* New translations ja-JP.yml (Chinese Traditional)
* New translations ja-JP.yml (English)
* New translations ja-JP.yml (Vietnamese)
* New translations ja-JP.yml (Indonesian)
* New translations ja-JP.yml (Bengali)
* New translations ja-JP.yml (Thai)
* New translations ja-JP.yml (Japanese, Kansai)
* New translations ja-JP.yml (Chinese Traditional)
* New translations ja-JP.yml (Chinese Traditional)
* New translations ja-JP.yml (German)
* New translations ja-JP.yml (English)
* New translations ja-JP.yml (German)
* New translations ja-JP.yml (Ukrainian)
* New translations ja-JP.yml (Chinese Simplified)
* New translations ja-JP.yml (English)
* New translations ja-JP.yml (Ukrainian)
* New translations ja-JP.yml (Ukrainian)
* New translations ja-JP.yml (Ukrainian)
* New translations ja-JP.yml (Chinese Simplified)
* New translations ja-JP.yml (Japanese, Kansai)
* New translations ja-JP.yml (Thai)
* New translations ja-JP.yml (Thai)
* New translations ja-JP.yml (Thai)
* New translations ja-JP.yml (Spanish)
* New translations ja-JP.yml (Spanish)
* enhance(client): improve user menu ux
* enhance(client): photoswipe 表示時に戻る操作をしても前の画面に戻らないように (#10098)
* enhance(client): photoswipe 表示時に戻る操作をしても前の画面に戻らないように
* add: changelog
---------
Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
* enhance(client): メニューの「もっと」からインスタンス情報を見れるように
* [Fix] fixed an typo in error message (#10102)
* Update codecov.yml
* Update CHANGELOG.md
* fix(server): エラーのスタックトレースは返さないように
Fix #10064
* [chore]Editorconfig: ymlに加えてyamlファイルに対しても同じ規約を適用する (#10081)
* Added yaml file in addition to yml file, in editorconfig
* Applied editorconfig for pnpm-workspace.yaml
---------
Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
* update deps
* ホームタイムラインの読み込みでクエリタイムアウトになるのを修正する (#10106)
* refactor
* New translations ja-JP.yml (French) (#10103)
* Update CHANGELOG.md
* 13.8.0
---------
Co-authored-by: atsuchan <83960488+atsu1125@users.noreply.github.com>
Co-authored-by: Masaya Suzuki <15100604+massongit@users.noreply.github.com>
Co-authored-by: tamaina <tamaina@hotmail.co.jp>
Co-authored-by: Kagami Sascha Rosylight <saschanaz@outlook.com>
Co-authored-by: taiy <53635909+taiyme@users.noreply.github.com>
Co-authored-by: xianon <xianon@hotmail.co.jp>
Co-authored-by: kabo2468 <28654659+kabo2468@users.noreply.github.com>
Co-authored-by: YS <47836716+yszkst@users.noreply.github.com>
Co-authored-by: Khsmty <me@khsmty.com>
Co-authored-by: Soni L <EnderMoneyMod@gmail.com>
Co-authored-by: mei23 <m@m544.net>
Co-authored-by: daima3629 <52790780+daima3629@users.noreply.github.com>
Co-authored-by: Windymelt <1113940+windymelt@users.noreply.github.com>
Diffstat (limited to 'packages/frontend/src')
29 files changed, 496 insertions, 328 deletions
diff --git a/packages/frontend/src/components/MkContextMenu.vue b/packages/frontend/src/components/MkContextMenu.vue index f0ea984c4e..21cccaabde 100644 --- a/packages/frontend/src/components/MkContextMenu.vue +++ b/packages/frontend/src/components/MkContextMenu.vue @@ -32,6 +32,8 @@ let rootEl = $shallowRef<HTMLDivElement>(); let zIndex = $ref<number>(os.claimZIndex('high')); +const SCROLLBAR_THICKNESS = 16; + onMounted(() => { let left = props.ev.pageX + 1; // 間違って右ダブルクリックした場合に意図せずアイテムがクリックされるのを防ぐため + 1 let top = props.ev.pageY + 1; // 間違って右ダブルクリックした場合に意図せずアイテムがクリックされるのを防ぐため + 1 @@ -39,12 +41,12 @@ onMounted(() => { const width = rootEl.offsetWidth; const height = rootEl.offsetHeight; - if (left + width - window.pageXOffset > window.innerWidth) { - left = window.innerWidth - width + window.pageXOffset; + if (left + width - window.pageXOffset >= (window.innerWidth - SCROLLBAR_THICKNESS)) { + left = (window.innerWidth - SCROLLBAR_THICKNESS) - width + window.pageXOffset; } - if (top + height - window.pageYOffset > window.innerHeight) { - top = window.innerHeight - height + window.pageYOffset; + if (top + height - window.pageYOffset >= (window.innerHeight - SCROLLBAR_THICKNESS)) { + top = (window.innerHeight - SCROLLBAR_THICKNESS) - height + window.pageYOffset; } if (top < 0) { diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue index a12bb78e35..fafa0bd232 100644 --- a/packages/frontend/src/components/MkMediaList.vue +++ b/packages/frontend/src/components/MkMediaList.vue @@ -113,6 +113,23 @@ onMounted(() => { }); lightbox.init(); + + window.addEventListener('popstate', () => { + if (lightbox.pswp && lightbox.pswp.isOpen === true) { + lightbox.pswp.close(); + return; + } + }); + + lightbox.on('beforeOpen', () => { + history.pushState(null, '', '#pswp'); + }); + + lightbox.on('close', () => { + if (window.location.hash === '#pswp') { + history.back(); + } + }); }); const previewable = (file: misskey.entities.DriveFile): boolean => { diff --git a/packages/frontend/src/components/MkMenu.child.vue b/packages/frontend/src/components/MkMenu.child.vue index cdd9d96b96..e0935efbe7 100644 --- a/packages/frontend/src/components/MkMenu.child.vue +++ b/packages/frontend/src/components/MkMenu.child.vue @@ -1,11 +1,11 @@ <template> -<div ref="el" class="sfhdhdhr"> - <MkMenu ref="menu" :items="items" :align="align" :width="width" :as-drawer="false" @close="onChildClosed"/> +<div ref="el" :class="$style.root"> + <MkMenu :items="items" :align="align" :width="width" :as-drawer="false" @close="onChildClosed"/> </div> </template> <script lang="ts" setup> -import { nextTick, onMounted, shallowRef, watch } from 'vue'; +import { nextTick, onMounted, onUnmounted, shallowRef, watch } from 'vue'; import MkMenu from './MkMenu.vue'; import { MenuItem } from '@/types/menu'; @@ -25,11 +25,21 @@ const emit = defineEmits<{ const el = shallowRef<HTMLElement>(); const align = 'left'; +const SCROLLBAR_THICKNESS = 16; + function setPosition() { const rootRect = props.rootElement.getBoundingClientRect(); - const rect = props.targetElement.getBoundingClientRect(); - const left = props.targetElement.offsetWidth; - const top = (rect.top - rootRect.top) - 8; + const parentRect = props.targetElement.getBoundingClientRect(); + const myRect = el.value.getBoundingClientRect(); + + let left = props.targetElement.offsetWidth; + let top = (parentRect.top - rootRect.top) - 8; + if (rootRect.left + left + myRect.width >= (window.innerWidth - SCROLLBAR_THICKNESS)) { + left = -myRect.width; + } + if (rootRect.top + top + myRect.height >= (window.innerHeight - SCROLLBAR_THICKNESS)) { + top = top - ((rootRect.top + top + myRect.height) - (window.innerHeight - SCROLLBAR_THICKNESS)); + } el.value.style.left = left + 'px'; el.value.style.top = top + 'px'; } @@ -46,13 +56,22 @@ watch(() => props.targetElement, () => { setPosition(); }); +const ro = new ResizeObserver((entries, observer) => { + setPosition(); +}); + onMounted(() => { + ro.observe(el.value); setPosition(); nextTick(() => { setPosition(); }); }); +onUnmounted(() => { + ro.disconnect(); +}); + defineExpose({ checkHit: (ev: MouseEvent) => { return (ev.target === el.value || el.value.contains(ev.target)); @@ -60,8 +79,8 @@ defineExpose({ }); </script> -<style lang="scss" scoped> -.sfhdhdhr { +<style lang="scss" module> +.root { position: absolute; } </style> diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue index 52aba58455..09d530c4ea 100644 --- a/packages/frontend/src/components/MkMenu.vue +++ b/packages/frontend/src/components/MkMenu.vue @@ -56,7 +56,7 @@ </template> <script lang="ts" setup> -import { defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, watch } from 'vue'; +import { defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { focusPrev, focusNext } from '@/scripts/focus'; import MkSwitch from '@/components/MkSwitch.vue'; import { MenuItem, InnerMenuItem, MenuPending, MenuAction } from '@/types/menu'; @@ -111,11 +111,11 @@ watch(() => props.items, () => { immediate: true, }); -let childMenu = $ref<MenuItem[] | null>(); +let childMenu = ref<MenuItem[] | null>(); let childTarget = $shallowRef<HTMLElement | null>(); function closeChild() { - childMenu = null; + childMenu.value = null; childShowingItem = null; } @@ -140,13 +140,31 @@ function onItemMouseLeave(item) { if (childCloseTimer) window.clearTimeout(childCloseTimer); } +let childrenCache = new WeakMap(); async function showChildren(item: MenuItem, ev: MouseEvent) { + const children = ref([]); + if (childrenCache.has(item)) { + children.value = childrenCache.get(item); + } else { + if (typeof item.children === 'function') { + children.value = [{ + type: 'pending', + }]; + item.children().then(x => { + children.value = x; + childrenCache.set(item, x); + }); + } else { + children.value = item.children; + } + } + if (props.asDrawer) { - os.popupMenu(item.children, ev.currentTarget ?? ev.target); + os.popupMenu(children, ev.currentTarget ?? ev.target); close(); } else { childTarget = ev.currentTarget ?? ev.target; - childMenu = item.children; + childMenu = children; childShowingItem = item; } } diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue index eba0f5847d..dbad02fb7e 100644 --- a/packages/frontend/src/components/MkModal.vue +++ b/packages/frontend/src/components/MkModal.vue @@ -125,7 +125,7 @@ function onBgClick() { } if (type === 'drawer') { - maxHeight = window.innerHeight / 1.5; + maxHeight = (window.innerHeight - SCROLLBAR_THICKNESS) / 1.5; } const keymap = { @@ -133,6 +133,7 @@ const keymap = { }; const MARGIN = 16; +const SCROLLBAR_THICKNESS = 16; const align = () => { if (props.src == null) return; @@ -170,15 +171,15 @@ const align = () => { if (fixed) { // 画面から横にはみ出る場合 - if (left + width > window.innerWidth) { - left = window.innerWidth - width; + if (left + width > (window.innerWidth - SCROLLBAR_THICKNESS)) { + left = (window.innerWidth - SCROLLBAR_THICKNESS) - width; } - const underSpace = (window.innerHeight - MARGIN) - top; + const underSpace = ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN) - top; const upperSpace = (srcRect.top - MARGIN); // 画面から縦にはみ出る場合 - if (top + height > (window.innerHeight - MARGIN)) { + if (top + height > ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN)) { if (props.noOverlap && props.anchor.x === 'center') { if (underSpace >= (upperSpace / 3)) { maxHeight = underSpace; @@ -187,22 +188,22 @@ const align = () => { top = (upperSpace + MARGIN) - height; } } else { - top = (window.innerHeight - MARGIN) - height; + top = ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN) - height; } } else { maxHeight = underSpace; } } else { // 画面から横にはみ出る場合 - if (left + width - window.pageXOffset > window.innerWidth) { - left = window.innerWidth - width + window.pageXOffset - 1; + if (left + width - window.pageXOffset > (window.innerWidth - SCROLLBAR_THICKNESS)) { + left = (window.innerWidth - SCROLLBAR_THICKNESS) - width + window.pageXOffset - 1; } - const underSpace = (window.innerHeight - MARGIN) - (top - window.pageYOffset); + const underSpace = ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN) - (top - window.pageYOffset); const upperSpace = (srcRect.top - MARGIN); // 画面から縦にはみ出る場合 - if (top + height - window.pageYOffset > (window.innerHeight - MARGIN)) { + if (top + height - window.pageYOffset > ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN)) { if (props.noOverlap && props.anchor.x === 'center') { if (underSpace >= (upperSpace / 3)) { maxHeight = underSpace; @@ -211,7 +212,7 @@ const align = () => { top = window.pageYOffset + ((upperSpace + MARGIN) - height); } } else { - top = (window.innerHeight - MARGIN) - height + window.pageYOffset - 1; + top = ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN) - height + window.pageYOffset - 1; } } else { maxHeight = underSpace; diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index ffc3133a85..1040dac12e 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -255,7 +255,7 @@ function renote(viaKeyboard = false) { text: i18n.ts.inChannelRenote, icon: 'ti ti-repeat', action: () => { - os.api('notes/create', { + os.apiWithDialog('notes/create', { renoteId: appearNote.id, channelId: appearNote.channelId, }); @@ -276,7 +276,7 @@ function renote(viaKeyboard = false) { text: i18n.ts.renote, icon: 'ti ti-repeat', action: () => { - os.api('notes/create', { + os.apiWithDialog('notes/create', { renoteId: appearNote.id, }); }, @@ -673,9 +673,17 @@ function showReactions(): void { opacity: 0.7; } -@container (max-width: 500px) { +@container (max-width: 580px) { .root { - font-size: 0.9em; + font-size: 0.95em; + } + + .renote { + padding: 12px 26px 0 26px; + } + + .article { + padding: 24px 26px 14px; } .avatar { @@ -684,7 +692,21 @@ function showReactions(): void { } } -@container (max-width: 450px) { +@container (max-width: 500px) { + .root { + font-size: 0.9em; + } + + .renote { + padding: 10px 22px 0 22px; + } + + .article { + padding: 20px 22px 12px; + } +} + +@container (max-width: 480px) { .renote { padding: 8px 16px 0 16px; } @@ -701,7 +723,9 @@ function showReactions(): void { .article { padding: 14px 16px 9px; } +} +@container (max-width: 450px) { .avatar { margin: 0 10px 8px 0; width: 46px; @@ -710,7 +734,7 @@ function showReactions(): void { } } -@container (max-width: 350px) { +@container (max-width: 400px) { .footerButton { &:not(:last-child) { margin-right: 18px; @@ -718,6 +742,14 @@ function showReactions(): void { } } +@container (max-width: 350px) { + .footerButton { + &:not(:last-child) { + margin-right: 12px; + } + } +} + @container (max-width: 300px) { .avatar { width: 44px; @@ -726,7 +758,7 @@ function showReactions(): void { .footerButton { &:not(:last-child) { - margin-right: 12px; + margin-right: 8px; } } } diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index 3ec1ce95f4..2eebe999a5 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -250,7 +250,7 @@ function renote(viaKeyboard = false) { text: i18n.ts.inChannelRenote, icon: 'ti ti-repeat', action: () => { - os.api('notes/create', { + os.apiWithDialog('notes/create', { renoteId: appearNote.id, channelId: appearNote.channelId, }); @@ -271,7 +271,7 @@ function renote(viaKeyboard = false) { text: i18n.ts.renote, icon: 'ti ti-repeat', action: () => { - os.api('notes/create', { + os.apiWithDialog('notes/create', { renoteId: appearNote.id, }); }, diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue index 84ba94361e..378d0ac020 100644 --- a/packages/frontend/src/components/MkPagination.vue +++ b/packages/frontend/src/components/MkPagination.vue @@ -42,6 +42,7 @@ import { computed, ComputedRef, isRef, nextTick, onActivated, onBeforeUnmount, o import * as misskey from 'misskey-js'; import * as os from '@/os'; import { onScrollTop, isTopVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scroll, isBottomVisible } from '@/scripts/scroll'; +import { useDocumentVisibility } from '@/scripts/use-document-visibility'; import MkButton from '@/components/MkButton.vue'; import { defaultStore } from '@/store'; import { MisskeyEntity } from '@/types/date-separated-list'; @@ -107,6 +108,12 @@ const { const contentEl = $computed(() => props.pagination.pageEl ?? rootEl); const scrollableElement = $computed(() => getScrollContainer(contentEl)); +const visibility = useDocumentVisibility(); + +let isPausingUpdate = false; +let timerForSetPause: number | null = null; +const BACKGROUND_PAUSE_WAIT_SEC = 10; + // 先頭が表示されているかどうかを検出 // https://qiita.com/mkataigi/items/0154aefd2223ce23398e let scrollObserver = $ref<IntersectionObserver>(); @@ -279,6 +286,28 @@ const fetchMoreAhead = async (): Promise<void> => { }); }; +const isTop = (): boolean => isBackTop.value || (props.pagination.reversed ? isBottomVisible : isTopVisible)(contentEl, TOLERANCE); + +watch(visibility, () => { + if (visibility.value === 'hidden') { + timerForSetPause = window.setTimeout(() => { + isPausingUpdate = true; + timerForSetPause = null; + }, + BACKGROUND_PAUSE_WAIT_SEC * 1000); + } else { // 'visible' + if (timerForSetPause) { + clearTimeout(timerForSetPause); + timerForSetPause = null; + } else { + isPausingUpdate = false; + if (isTop()) { + executeQueue(); + } + } + } +}); + const prepend = (item: MisskeyEntity): void => { // 初回表示時はunshiftだけでOK if (!rootEl) { @@ -286,9 +315,7 @@ const prepend = (item: MisskeyEntity): void => { return; } - const isTop = isBackTop.value || (props.pagination.reversed ? isBottomVisible : isTopVisible)(contentEl, TOLERANCE); - - if (isTop) unshiftItems([item]); + if (isTop() && !isPausingUpdate) unshiftItems([item]); else prependQueue(item); }; @@ -357,6 +384,10 @@ onMounted(() => { }); onBeforeUnmount(() => { + if (timerForSetPause) { + clearTimeout(timerForSetPause); + timerForSetPause = null; + } scrollObserver.disconnect(); }); diff --git a/packages/frontend/src/components/MkUrlPreview.vue b/packages/frontend/src/components/MkUrlPreview.vue index b97b7cf07b..5381ecbfa5 100644 --- a/packages/frontend/src/components/MkUrlPreview.vue +++ b/packages/frontend/src/components/MkUrlPreview.vue @@ -1,12 +1,25 @@ <template> -<div v-if="playerEnabled" :class="$style.player" :style="`padding: ${(player.height || 0) / (player.width || 1) * 100}% 0 0`"> - <button :class="$style.disablePlayer" :title="i18n.ts.disablePlayer" @click="playerEnabled = false"><i class="ti ti-x"></i></button> - <iframe v-if="player.url.startsWith('http://') || player.url.startsWith('https://')" :class="$style.playerIframe" :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" :width="player.width || '100%'" :heigth="player.height || 250" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen/> - <span v-else>invalid url</span> -</div> -<div v-else-if="tweetId && tweetExpanded" ref="twitter" :class="$style.twitter"> - <iframe ref="tweet" scrolling="no" frameborder="no" :style="{ position: 'relative', width: '100%', height: `${tweetHeight}px` }" :src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&hideCard=false&hideThread=false&lang=en&theme=${$store.state.darkMode ? 'dark' : 'light'}&id=${tweetId}`"></iframe> -</div> +<template v-if="playerEnabled"> + <div :class="$style.player" :style="`padding: ${(player.height || 0) / (player.width || 1) * 100}% 0 0`"> + <iframe v-if="player.url.startsWith('http://') || player.url.startsWith('https://')" :class="$style.playerIframe" :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" :width="player.width || '100%'" :heigth="player.height || 250" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen/> + <span v-else>invalid url</span> + </div> + <div :class="$style.action"> + <MkButton :small="true" inline @click="playerEnabled = false"> + <i class="ti ti-x"></i> {{ i18n.ts.disablePlayer }} + </MkButton> + </div> +</template> +<template v-else-if="tweetId && tweetExpanded"> + <div ref="twitter" :class="$style.twitter"> + <iframe ref="tweet" scrolling="no" frameborder="no" :style="{ position: 'relative', width: '100%', height: `${tweetHeight}px` }" :src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&hideCard=false&hideThread=false&lang=en&theme=${$store.state.darkMode ? 'dark' : 'light'}&id=${tweetId}`"></iframe> + </div> + <div :class="$style.action"> + <MkButton :small="true" inline @click="tweetExpanded = false"> + <i class="ti ti-x"></i> {{ i18n.ts.close }} + </MkButton> + </div> +</template> <div v-else :class="$style.urlPreview"> <component :is="self ? 'MkA' : 'a'" :class="[$style.link, { [$style.compact]: compact }]" :[attr]="self ? url.substr(local.length) : url" rel="nofollow noopener" :target="target" :title="url"> <div v-if="thumbnail" :class="$style.thumbnail" :style="`background-image: url('${thumbnail}')`"> diff --git a/packages/frontend/src/components/global/MkPageHeader.tabs.vue b/packages/frontend/src/components/global/MkPageHeader.tabs.vue index 3beedf34b2..42760da08f 100644 --- a/packages/frontend/src/components/global/MkPageHeader.tabs.vue +++ b/packages/frontend/src/components/global/MkPageHeader.tabs.vue @@ -103,7 +103,7 @@ function onTabWheel(ev: WheelEvent) { ev.stopPropagation(); (ev.currentTarget as HTMLElement).scrollBy({ left: ev.deltaY, - behavior: 'smooth', + behavior: 'instant', }); } return false; diff --git a/packages/frontend/src/components/global/MkPageHeader.vue b/packages/frontend/src/components/global/MkPageHeader.vue index a4e25bbe1a..589ca92d75 100644 --- a/packages/frontend/src/components/global/MkPageHeader.vue +++ b/packages/frontend/src/components/global/MkPageHeader.vue @@ -147,10 +147,7 @@ onUnmounted(() => { .tabs:first-child { margin-left: auto; - } - .tabs:not(:first-child) { - padding-left: 16px; - mask-image: linear-gradient(90deg, rgba(0,0,0,0), rgb(0,0,0) 16px, rgb(0,0,0) 100%); + padding: 0 12px; } .tabs { margin-right: auto; diff --git a/packages/frontend/src/components/global/MkTime.vue b/packages/frontend/src/components/global/MkTime.vue index 66c0bd5135..3fa8bb9adc 100644 --- a/packages/frontend/src/components/global/MkTime.vue +++ b/packages/frontend/src/components/global/MkTime.vue @@ -1,6 +1,7 @@ <template> <time :title="absolute"> - <template v-if="mode === 'relative'">{{ relative }}</template> + <template v-if="invalid">{{ i18n.ts._ago.invalid }}</template> + <template v-else-if="mode === 'relative'">{{ relative }}</template> <template v-else-if="mode === 'absolute'">{{ absolute }}</template> <template v-else-if="mode === 'detail'">{{ absolute }} ({{ relative }})</template> </time> @@ -12,18 +13,24 @@ import { i18n } from '@/i18n'; import { dateTimeFormat } from '@/scripts/intl-const'; const props = withDefaults(defineProps<{ - time: Date | string; + time: Date | string | number | null; mode?: 'relative' | 'absolute' | 'detail'; }>(), { mode: 'relative', }); -const _time = typeof props.time === 'string' ? new Date(props.time) : props.time; -const absolute = dateTimeFormat.format(_time); +const _time = props.time == null ? NaN : + typeof props.time === 'number' ? props.time : + (props.time instanceof Date ? props.time : new Date(props.time)).getTime(); +const invalid = Number.isNaN(_time); +const absolute = !invalid ? dateTimeFormat.format(_time) : i18n.ts._ago.invalid; -let now = $shallowRef(new Date()); -const relative = $computed(() => { - const ago = (now.getTime() - _time.getTime()) / 1000/*ms*/; +let now = $ref((new Date()).getTime()); +const relative = $computed<string>(() => { + if (props.mode === 'absolute') return ''; // absoluteではrelativeを使わないので計算しない + if (invalid) return i18n.ts._ago.invalid; + + const ago = (now - _time) / 1000/*ms*/; return ( ago >= 31536000 ? i18n.t('_ago.yearsAgo', { n: Math.round(ago / 31536000).toString() }) : ago >= 2592000 ? i18n.t('_ago.monthsAgo', { n: Math.round(ago / 2592000).toString() }) : @@ -39,8 +46,8 @@ const relative = $computed(() => { let tickId: number; function tick() { - now = new Date(); - const ago = (now.getTime() - _time.getTime()) / 1000/*ms*/; + now = (new Date()).getTime(); + const ago = (now - _time) / 1000/*ms*/; const next = ago < 60 ? 10000 : ago < 3600 ? 60000 : 180000; tickId = window.setTimeout(tick, next); diff --git a/packages/frontend/src/init.ts b/packages/frontend/src/init.ts index 8c657295f9..0a626b36c6 100644 --- a/packages/frontend/src/init.ts +++ b/packages/frontend/src/init.ts @@ -36,7 +36,6 @@ import { $i, refreshAccount, login, updateAccount, signout } from '@/account'; import { defaultStore, ColdDeviceStorage } from '@/store'; import { fetchInstance, instance } from '@/instance'; import { makeHotkey } from '@/scripts/hotkey'; -import { search } from '@/scripts/search'; import { deviceKind } from '@/scripts/device-kind'; import { initializeSw } from '@/scripts/initialize-sw'; import { reloadChannel } from '@/scripts/unison-reload'; @@ -47,6 +46,7 @@ import { deckStore } from './ui/deck/deck-store'; import { miLocalStorage } from './local-storage'; import { claimAchievement, claimedAchievements } from './scripts/achievements'; import { fetchCustomEmojis } from './custom-emojis'; +import { mainRouter } from './router'; console.info(`Misskey v${version}`); @@ -352,7 +352,9 @@ const hotkeys = { 'd': (): void => { defaultStore.set('darkMode', !defaultStore.state.darkMode); }, - 's': search, + 's': (): void => { + mainRouter.push('/search'); + } }; if ($i) { diff --git a/packages/frontend/src/navbar.ts b/packages/frontend/src/navbar.ts index 95bf6e8181..efc0e8c920 100644 --- a/packages/frontend/src/navbar.ts +++ b/packages/frontend/src/navbar.ts @@ -1,7 +1,7 @@ import { computed, reactive } from 'vue'; import { $i } from './account'; import { miLocalStorage } from './local-storage'; -import { search } from '@/scripts/search'; +import { openInstanceMenu } from './ui/_common_/common'; import * as os from '@/os'; import { i18n } from '@/i18n'; import { ui } from '@/config'; @@ -42,7 +42,7 @@ export const navbarItemDef = reactive({ search: { title: i18n.ts.search, icon: 'ti ti-search', - action: () => search(), + to: '/search', }, lists: { title: i18n.ts.lists, @@ -122,6 +122,13 @@ export const navbarItemDef = reactive({ }], ev.currentTarget ?? ev.target); }, }, + about: { + title: i18n.ts.about, + icon: 'ti ti-info-circle', + action: (ev) => { + openInstanceMenu(ev); + }, + }, reload: { title: i18n.ts.reload, icon: 'ti ti-refresh', diff --git a/packages/frontend/src/pages/about-misskey.vue b/packages/frontend/src/pages/about-misskey.vue index 782fe9fdb2..a49025c920 100644 --- a/packages/frontend/src/pages/about-misskey.vue +++ b/packages/frontend/src/pages/about-misskey.vue @@ -203,6 +203,7 @@ const patrons = [ 'ThatOneCalculator', 'pixeldesu', 'あめ玉', + '氷月氷華里', ]; let thereIsTreasure = $ref($i && !claimedAchievements.includes('foundTreasure')); diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue index f59b7d7f90..6b4fcb32f8 100644 --- a/packages/frontend/src/pages/channel.vue +++ b/packages/frontend/src/pages/channel.vue @@ -1,9 +1,9 @@ <template> <MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :content-max="700"> - <div v-if="channel"> - <div class="wpgynlbz _panel _margin" :class="{ hide: !showBanner }"> + <div v-if="channel && tab === 'timeline'" class="_gaps"> + <div class="wpgynlbz _panel" :class="{ hide: !showBanner }"> <XChannelFollowButton :channel="channel" :full="true" class="subscribe"/> <button class="_button toggle" @click="() => showBanner = !showBanner"> <template v-if="showBanner"><i class="ti ti-chevron-up"></i></template> @@ -24,9 +24,12 @@ </div> <!-- スマホ・タブレットの場合、キーボードが表示されると投稿が見づらくなるので、デスクトップ場合のみ自動でフォーカスを当てる --> - <MkPostForm v-if="$i" :channel="channel" class="post-form _panel _margin" fixed :autofocus="deviceKind === 'desktop'"/> + <MkPostForm v-if="$i" :channel="channel" class="post-form _panel" fixed :autofocus="deviceKind === 'desktop'"/> - <MkTimeline :key="channelId" class="_margin" src="channel" :channel="channelId" @before="before" @after="after"/> + <MkTimeline :key="channelId" src="channel" :channel="channelId" @before="before" @after="after"/> + </div> + <div v-else-if="tab === 'featured'"> + <MkNotes :pagination="featuredPagination"/> </div> </MkSpacer> </MkStickyContainer> @@ -43,6 +46,7 @@ import { $i } from '@/account'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; import { deviceKind } from '@/scripts/device-kind'; +import MkNotes from '@/components/MkNotes.vue'; const router = useRouter(); @@ -50,15 +54,17 @@ const props = defineProps<{ channelId: string; }>(); +let tab = $ref('timeline'); let channel = $ref(null); let showBanner = $ref(true); -const pagination = { - endpoint: 'channels/timeline' as const, +const featuredPagination = $computed(() => ({ + endpoint: 'notes/featured' as const, limit: 10, - params: computed(() => ({ + offsetMode: true, + params: { channelId: props.channelId, - })), -}; + }, +})); watch(() => props.channelId, async () => { channel = await os.api('channels/show', { @@ -76,7 +82,15 @@ const headerActions = $computed(() => channel && channel.userId ? [{ handler: edit, }] : null); -const headerTabs = $computed(() => []); +const headerTabs = $computed(() => [{ + key: 'timeline', + title: i18n.ts.timeline, + icon: 'ti ti-home', +}, { + key: 'featured', + title: i18n.ts.featured, + icon: 'ti ti-bolt', +}]); definePageMetadata(computed(() => channel ? { title: channel.name, diff --git a/packages/frontend/src/pages/explore.vue b/packages/frontend/src/pages/explore.vue index 0ed0a7ebc2..2131188dde 100644 --- a/packages/frontend/src/pages/explore.vue +++ b/packages/frontend/src/pages/explore.vue @@ -11,23 +11,6 @@ <div v-else-if="tab === 'roles'"> <XRoles/> </div> - <div v-else-if="tab === 'search'"> - <MkSpacer :content-max="1200"> - <div> - <MkInput v-model="searchQuery" :debounce="true" type="search"> - <template #prefix><i class="ti ti-search"></i></template> - <template #label>{{ i18n.ts.searchUser }}</template> - </MkInput> - <MkRadios v-model="searchOrigin"> - <option value="combined">{{ i18n.ts.all }}</option> - <option value="local">{{ i18n.ts.local }}</option> - <option value="remote">{{ i18n.ts.remote }}</option> - </MkRadios> - </div> - - <MkUserList v-if="searchQuery" ref="searchEl" class="_margin" :pagination="searchPagination"/> - </MkSpacer> - </div> </div> </MkStickyContainer> </template> @@ -38,11 +21,8 @@ import XFeatured from './explore.featured.vue'; import XUsers from './explore.users.vue'; import XRoles from './explore.roles.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; -import MkInput from '@/components/MkInput.vue'; -import MkRadios from '@/components/MkRadios.vue'; import { definePageMetadata } from '@/scripts/page-metadata'; import { i18n } from '@/i18n'; -import MkUserList from '@/components/MkUserList.vue'; const props = withDefaults(defineProps<{ tag?: string; @@ -53,22 +33,11 @@ const props = withDefaults(defineProps<{ let tab = $ref(props.initialTab); let tagsEl = $shallowRef<InstanceType<typeof MkFoldableSection>>(); -let searchQuery = $ref(null); -let searchOrigin = $ref('combined'); watch(() => props.tag, () => { if (tagsEl) tagsEl.toggleContent(props.tag == null); }); -const searchPagination = { - endpoint: 'users/search' as const, - limit: 10, - params: computed(() => (searchQuery && searchQuery !== '') ? { - query: searchQuery, - origin: searchOrigin, - } : null), -}; - const headerActions = $computed(() => []); const headerTabs = $computed(() => [{ @@ -83,10 +52,6 @@ const headerTabs = $computed(() => [{ key: 'roles', icon: 'ti ti-badges', title: i18n.ts.roles, -}, { - key: 'search', - icon: 'ti ti-search', - title: i18n.ts.search, }]); definePageMetadata(computed(() => ({ diff --git a/packages/frontend/src/pages/search.vue b/packages/frontend/src/pages/search.vue index e52c97b350..7e81cd2c0d 100644 --- a/packages/frontend/src/pages/search.vue +++ b/packages/frontend/src/pages/search.vue @@ -2,33 +2,104 @@ <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :content-max="800"> - <MkNotes ref="notes" :pagination="pagination"/> + <MkInput v-model="searchQuery" :large="true" :autofocus="true" :debounce="true" type="search" style="margin-bottom: var(--margin);" @update:model-value="search()"> + <template #prefix><i class="ti ti-search"></i></template> + </MkInput> + <MkTab v-model="searchType" style="margin-bottom: var(--margin);" @update:model-value="search()"> + <option value="note">{{ i18n.ts.note }}</option> + <option value="user">{{ i18n.ts.user }}</option> + </MkTab> + + <div v-if="searchType === 'note'"> + <MkNotes v-if="searchQuery" ref="notes" :pagination="notePagination"/> + </div> + <div v-else> + <MkRadios v-model="searchOrigin" style="margin-bottom: var(--margin);" @update:model-value="search()"> + <option value="combined">{{ i18n.ts.all }}</option> + <option value="local">{{ i18n.ts.local }}</option> + <option value="remote">{{ i18n.ts.remote }}</option> + </MkRadios> + + <MkUserList v-if="searchQuery" ref="users" :pagination="userPagination"/> + </div> </MkSpacer> </MkStickyContainer> </template> <script lang="ts" setup> -import { computed } from 'vue'; +import { computed, onMounted } from 'vue'; import MkNotes from '@/components/MkNotes.vue'; +import MkUserList from '@/components/MkUserList.vue'; +import MkInput from '@/components/MkInput.vue'; +import MkTab from '@/components/MkTab.vue'; +import MkRadios from '@/components/MkRadios.vue'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; import * as os from '@/os'; -import { useRouter } from '@/router'; -import { $i } from '@/account'; +import { useRouter, mainRouter } from '@/router'; const router = useRouter(); const props = defineProps<{ query: string; channel?: string; + type?: string; + origin?: string; }>(); -const query = props.query; +let searchQuery = $ref(''); +let searchType = $ref('note'); +let searchOrigin = $ref('combined'); + +onMounted(() => { + searchQuery = props.query ?? ''; + searchType = props.type ?? 'note'; + searchOrigin = props.origin ?? 'combined'; + + if (searchQuery) { + search(); + } +}); + +const search = async () => { + const query = searchQuery.toString().trim(); + + if (query == null || query === '') return; + + if (query.startsWith('@') && !query.includes(' ')) { + mainRouter.push(`/${query}`); + return; + } + + if (query.startsWith('#')) { + mainRouter.push(`/tags/${encodeURIComponent(query.substr(1))}`); + return; + } + + // like 2018/03/12 + if (/^[0-9]{4}\/[0-9]{2}\/[0-9]{2}/.test(query.replace(/-/g, '/'))) { + const date = new Date(query.replace(/-/g, '/')); -if ($i != null) { - if (query.startsWith('https://') || (query.startsWith('@') && !query.includes(' '))) { + // 日付しか指定されてない場合、例えば 2018/03/12 ならユーザーは + // 2018/03/12 のコンテンツを「含む」結果になることを期待するはずなので + // 23時間59分進める(そのままだと 2018/03/12 00:00:00 「まで」の + // 結果になってしまい、2018/03/12 のコンテンツは含まれない) + if (query.replace(/-/g, '/').match(/^[0-9]{4}\/[0-9]{2}\/[0-9]{2}$/)) { + date.setHours(23, 59, 59, 999); + } + + // TODO + //v.$root.$emit('warp', date); + os.alert({ + icon: 'ti ti-history', + iconOnly: true, autoClose: true, + }); + return; + } + + if (query.startsWith('https://')) { const promise = os.api('ap/show', { - uri: props.query, + uri: query, }); os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject); @@ -36,28 +107,40 @@ if ($i != null) { const res = await promise; if (res.type === 'User') { - router.replace(`/@${res.object.username}@${res.object.host}`); + mainRouter.push(`/@${res.object.username}@${res.object.host}`); } else if (res.type === 'Note') { - router.replace(`/notes/${res.object.id}`); + mainRouter.push(`/notes/${res.object.id}`); } + + return; } -} -const pagination = { + window.history.replaceState('', '', `/search?q=${encodeURIComponent(query)}&type=${searchType}${searchType === 'user' ? `&origin=${searchOrigin}` : ''}`); +}; + +const notePagination = { endpoint: 'notes/search' as const, limit: 10, params: computed(() => ({ - query: props.query, + query: searchQuery, channelId: props.channel, })), }; +const userPagination = { + endpoint: 'users/search' as const, + limit: 10, + params: computed(() => ({ + query: searchQuery, + origin: searchOrigin, + })), +}; const headerActions = $computed(() => []); const headerTabs = $computed(() => []); definePageMetadata(computed(() => ({ - title: i18n.t('searchWith', { q: props.query }), + title: searchQuery ? i18n.t('searchWith', { q: searchQuery }) : i18n.ts.search, icon: 'ti ti-search', }))); </script> diff --git a/packages/frontend/src/pages/settings/plugin.vue b/packages/frontend/src/pages/settings/plugin.vue index b3459a2e2e..8b57dceefb 100644 --- a/packages/frontend/src/pages/settings/plugin.vue +++ b/packages/frontend/src/pages/settings/plugin.vue @@ -4,27 +4,29 @@ <FormSection> <template #label>{{ i18n.ts.manage }}</template> - <div v-for="plugin in plugins" :key="plugin.id" class="_panel _gaps_s" style="padding: 20px;"> - <span style="display: flex;"><b>{{ plugin.name }}</b><span style="margin-left: auto;">v{{ plugin.version }}</span></span> + <div class="_gaps_s"> + <div v-for="plugin in plugins" :key="plugin.id" class="_panel _gaps_s" style="padding: 20px;"> + <span style="display: flex;"><b>{{ plugin.name }}</b><span style="margin-left: auto;">v{{ plugin.version }}</span></span> - <MkSwitch :model-value="plugin.active" @update:model-value="changeActive(plugin, $event)">{{ i18n.ts.makeActive }}</MkSwitch> + <MkSwitch :model-value="plugin.active" @update:model-value="changeActive(plugin, $event)">{{ i18n.ts.makeActive }}</MkSwitch> - <MkKeyValue> - <template #key>{{ i18n.ts.author }}</template> - <template #value>{{ plugin.author }}</template> - </MkKeyValue> - <MkKeyValue> - <template #key>{{ i18n.ts.description }}</template> - <template #value>{{ plugin.description }}</template> - </MkKeyValue> - <MkKeyValue> - <template #key>{{ i18n.ts.permission }}</template> - <template #value>{{ plugin.permission }}</template> - </MkKeyValue> + <MkKeyValue> + <template #key>{{ i18n.ts.author }}</template> + <template #value>{{ plugin.author }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>{{ i18n.ts.description }}</template> + <template #value>{{ plugin.description }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>{{ i18n.ts.permission }}</template> + <template #value>{{ plugin.permission }}</template> + </MkKeyValue> - <div class="_buttons"> - <MkButton v-if="plugin.config" inline @click="config(plugin)"><i class="ti ti-settings"></i> {{ i18n.ts.settings }}</MkButton> - <MkButton inline danger @click="uninstall(plugin)"><i class="ti ti-trash"></i> {{ i18n.ts.uninstall }}</MkButton> + <div class="_buttons"> + <MkButton v-if="plugin.config" inline @click="config(plugin)"><i class="ti ti-settings"></i> {{ i18n.ts.settings }}</MkButton> + <MkButton inline danger @click="uninstall(plugin)"><i class="ti ti-trash"></i> {{ i18n.ts.uninstall }}</MkButton> + </div> </div> </div> </FormSection> diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts index 9521e01910..70576688b1 100644 --- a/packages/frontend/src/router.ts +++ b/packages/frontend/src/router.ts @@ -213,6 +213,8 @@ export const routes = [{ query: { q: 'query', channel: 'channel', + type: 'type', + origin: 'origin', }, }, { path: '/authorize-follow', diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/scripts/get-note-menu.ts index 9da7447bfd..9c0ff3d1b2 100644 --- a/packages/frontend/src/scripts/get-note-menu.ts +++ b/packages/frontend/src/scripts/get-note-menu.ts @@ -9,6 +9,7 @@ import copyToClipboard from '@/scripts/copy-to-clipboard'; import { url } from '@/config'; import { noteActions } from '@/store'; import { miLocalStorage } from '@/local-storage'; +import { getUserMenu } from '@/scripts/get-user-menu'; export function getNoteMenu(props: { note: misskey.entities.Note; @@ -99,66 +100,6 @@ export function getNoteMenu(props: { }); } - async function clip(): Promise<void> { - const clips = await os.api('clips/list'); - os.popupMenu([{ - icon: 'ti ti-plus', - text: i18n.ts.createNew, - action: async () => { - const { canceled, result } = await os.form(i18n.ts.createNewClip, { - name: { - type: 'string', - label: i18n.ts.name, - }, - description: { - type: 'string', - required: false, - multiline: true, - label: i18n.ts.description, - }, - isPublic: { - type: 'boolean', - label: i18n.ts.public, - default: false, - }, - }); - if (canceled) return; - - const clip = await os.apiWithDialog('clips/create', result); - - claimAchievement('noteClipped1'); - os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: appearNote.id }); - }, - }, null, ...clips.map(clip => ({ - text: clip.name, - action: () => { - claimAchievement('noteClipped1'); - os.promiseDialog( - os.api('clips/add-note', { clipId: clip.id, noteId: appearNote.id }), - null, - async (err) => { - if (err.id === '734806c4-542c-463a-9311-15c512803965') { - const confirm = await os.confirm({ - type: 'warning', - text: i18n.t('confirmToUnclipAlreadyClippedNote', { name: clip.name }), - }); - if (!confirm.canceled) { - os.apiWithDialog('clips/remove-note', { clipId: clip.id, noteId: appearNote.id }); - if (props.currentClipPage?.value.id === clip.id) props.isDeleted.value = true; - } - } else { - os.alert({ - type: 'error', - text: err.message + '\n' + err.id, - }); - } - }, - ); - }, - }))], props.menuButton.value, { - }).then(focus); - } - async function unclip(): Promise<void> { os.apiWithDialog('clips/remove-note', { clipId: props.currentClipPage.value.id, noteId: appearNote.id }); props.isDeleted.value = true; @@ -264,9 +205,67 @@ export function getNoteMenu(props: { action: () => toggleFavorite(true), }), { + type: 'parent', icon: 'ti ti-paperclip', text: i18n.ts.clip, - action: () => clip(), + children: async () => { + const clips = await os.api('clips/list'); + return [{ + icon: 'ti ti-plus', + text: i18n.ts.createNew, + action: async () => { + const { canceled, result } = await os.form(i18n.ts.createNewClip, { + name: { + type: 'string', + label: i18n.ts.name, + }, + description: { + type: 'string', + required: false, + multiline: true, + label: i18n.ts.description, + }, + isPublic: { + type: 'boolean', + label: i18n.ts.public, + default: false, + }, + }); + if (canceled) return; + + const clip = await os.apiWithDialog('clips/create', result); + + claimAchievement('noteClipped1'); + os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: appearNote.id }); + }, + }, null, ...clips.map(clip => ({ + text: clip.name, + action: () => { + claimAchievement('noteClipped1'); + os.promiseDialog( + os.api('clips/add-note', { clipId: clip.id, noteId: appearNote.id }), + null, + async (err) => { + if (err.id === '734806c4-542c-463a-9311-15c512803965') { + const confirm = await os.confirm({ + type: 'warning', + text: i18n.t('confirmToUnclipAlreadyClippedNote', { name: clip.name }), + }); + if (!confirm.canceled) { + os.apiWithDialog('clips/remove-note', { clipId: clip.id, noteId: appearNote.id }); + if (props.currentClipPage?.value.id === clip.id) props.isDeleted.value = true; + } + } else { + os.alert({ + type: 'error', + text: err.message + '\n' + err.id, + }); + } + }, + ); + }, + }))]; + }, }, statePromise.then(state => state.isMutedThread ? { icon: 'ti ti-message-off', @@ -286,6 +285,15 @@ export function getNoteMenu(props: { text: i18n.ts.pin, action: () => togglePin(true), } : undefined, + appearNote.userId !== $i.id ? { + type: 'parent', + icon: 'ti ti-user', + text: i18n.ts.user, + children: async () => { + const user = await os.api('users/show', { userId: appearNote.userId }); + return getUserMenu(user); + }, + } : undefined, /* ...($i.isModerator || $i.isAdmin ? [ null, diff --git a/packages/frontend/src/scripts/get-user-menu.ts b/packages/frontend/src/scripts/get-user-menu.ts index 557b257f62..6c6baf8266 100644 --- a/packages/frontend/src/scripts/get-user-menu.ts +++ b/packages/frontend/src/scripts/get-user-menu.ts @@ -1,4 +1,5 @@ import { defineAsyncComponent } from 'vue'; +import * as misskey from 'misskey-js'; import { i18n } from '@/i18n'; import copyToClipboard from '@/scripts/copy-to-clipboard'; import { host } from '@/config'; @@ -8,32 +9,9 @@ import { $i, iAmModerator } from '@/account'; import { mainRouter } from '@/router'; import { Router } from '@/nirax'; -export function getUserMenu(user, router: Router = mainRouter) { +export function getUserMenu(user: misskey.entities.UserDetailed, router: Router = mainRouter) { const meId = $i ? $i.id : null; - async function pushList() { - const t = i18n.ts.selectList; // なぜか後で参照すると null になるので最初にメモリに確保しておく - const lists = await os.api('users/lists/list'); - if (lists.length === 0) { - os.alert({ - type: 'error', - text: i18n.ts.youHaveNoLists, - }); - return; - } - const { canceled, result: listId } = await os.select({ - title: t, - items: lists.map(list => ({ - value: list.id, text: list.name, - })), - }); - if (canceled) return; - os.apiWithDialog('users/lists/push', { - listId: listId, - userId: user.id, - }); - } - async function toggleMute() { if (user.isMuted) { os.apiWithDialog('mute/delete', { @@ -102,6 +80,8 @@ export function getUserMenu(user, router: Router = mainRouter) { } async function invalidateFollow() { + if (!await getConfirmed(i18n.ts.breakFollowConfirm)) return; + os.apiWithDialog('following/invalidate', { userId: user.id, }).then(() => { @@ -113,7 +93,7 @@ export function getUserMenu(user, router: Router = mainRouter) { icon: 'ti ti-at', text: i18n.ts.copyUsername, action: () => { - copyToClipboard(`@${user.username}@${user.host || host}`); + copyToClipboard(`@${user.username}@${user.host ?? host}`); }, }, { icon: 'ti ti-info-circle', @@ -134,12 +114,43 @@ export function getUserMenu(user, router: Router = mainRouter) { os.post({ specified: user }); }, }, null, { + type: 'parent', icon: 'ti ti-list', text: i18n.ts.addToList, - action: pushList, + children: async () => { + const lists = await os.api('users/lists/list'); + + return lists.map(list => ({ + text: list.name, + action: () => { + os.apiWithDialog('users/lists/push', { + listId: list.id, + userId: user.id, + }); + }, + })); + }, }] as any; if ($i && meId !== user.id) { + if (iAmModerator) { + menu = menu.concat([{ + type: 'parent', + icon: 'ti ti-badges', + text: i18n.ts.roles, + children: async () => { + const roles = await os.api('admin/roles/list'); + + return roles.filter(r => r.target === 'manual').map(r => ({ + text: r.name, + action: () => { + os.apiWithDialog('admin/roles/assign', { roleId: r.id, userId: user.id }); + }, + })); + }, + }]); + } + menu = menu.concat([null, { icon: user.isMuted ? 'ti ti-eye' : 'ti ti-eye-off', text: user.isMuted ? i18n.ts.unmute : i18n.ts.mute, @@ -163,30 +174,6 @@ export function getUserMenu(user, router: Router = mainRouter) { text: i18n.ts.reportAbuse, action: reportAbuse, }]); - - if (iAmModerator) { - menu = menu.concat([null, { - icon: 'ti ti-user-exclamation', - text: i18n.ts.moderation, - action: () => { - router.push('/user-info/' + user.id + '#moderation'); - }, - }, { - icon: 'ti ti-badges', - text: i18n.ts.roles, - action: async () => { - const roles = await os.api('admin/roles/list'); - - const { canceled, result: roleId } = await os.select({ - title: i18n.ts._role.chooseRoleToAssign, - items: roles.map(r => ({ text: r.name, value: r.id })), - }); - if (canceled) return; - - await os.apiWithDialog('admin/roles/assign', { roleId, userId: user.id }); - }, - }]); - } } if ($i && meId === user.id) { diff --git a/packages/frontend/src/scripts/search.ts b/packages/frontend/src/scripts/search.ts deleted file mode 100644 index 69f1586b77..0000000000 --- a/packages/frontend/src/scripts/search.ts +++ /dev/null @@ -1,63 +0,0 @@ -import * as os from '@/os'; -import { i18n } from '@/i18n'; -import { mainRouter } from '@/router'; - -export async function search() { - const { canceled, result: query } = await os.inputText({ - title: i18n.ts.search, - }); - if (canceled || query == null || query === '') return; - - const q = query.trim(); - - if (q.startsWith('@') && !q.includes(' ')) { - mainRouter.push(`/${q}`); - return; - } - - if (q.startsWith('#')) { - mainRouter.push(`/tags/${encodeURIComponent(q.substr(1))}`); - return; - } - - // like 2018/03/12 - if (/^[0-9]{4}\/[0-9]{2}\/[0-9]{2}/.test(q.replace(/-/g, '/'))) { - const date = new Date(q.replace(/-/g, '/')); - - // 日付しか指定されてない場合、例えば 2018/03/12 ならユーザーは - // 2018/03/12 のコンテンツを「含む」結果になることを期待するはずなので - // 23時間59分進める(そのままだと 2018/03/12 00:00:00 「まで」の - // 結果になってしまい、2018/03/12 のコンテンツは含まれない) - if (q.replace(/-/g, '/').match(/^[0-9]{4}\/[0-9]{2}\/[0-9]{2}$/)) { - date.setHours(23, 59, 59, 999); - } - - // TODO - //v.$root.$emit('warp', date); - os.alert({ - icon: 'ti ti-history', - iconOnly: true, autoClose: true, - }); - return; - } - - if (q.startsWith('https://')) { - const promise = os.api('ap/show', { - uri: q, - }); - - os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject); - - const res = await promise; - - if (res.type === 'User') { - mainRouter.push(`/@${res.object.username}@${res.object.host}`); - } else if (res.type === 'Note') { - mainRouter.push(`/notes/${res.object.id}`); - } - - return; - } - - mainRouter.push(`/search?q=${encodeURIComponent(q)}`); -} diff --git a/packages/frontend/src/scripts/use-document-visibility.ts b/packages/frontend/src/scripts/use-document-visibility.ts new file mode 100644 index 0000000000..47e91dd937 --- /dev/null +++ b/packages/frontend/src/scripts/use-document-visibility.ts @@ -0,0 +1,19 @@ +import { onMounted, onUnmounted, ref, Ref } from 'vue'; + +export function useDocumentVisibility(): Ref<DocumentVisibilityState> { + const visibility = ref(document.visibilityState); + + const onChange = (): void => { + visibility.value = document.visibilityState; + }; + + onMounted(() => { + document.addEventListener('visibilitychange', onChange); + }); + + onUnmounted(() => { + document.removeEventListener('visibilitychange', onChange); + }); + + return visibility; +} diff --git a/packages/frontend/src/ui/classic.header.vue b/packages/frontend/src/ui/classic.header.vue index 34ddfa1d32..3dfb371d32 100644 --- a/packages/frontend/src/ui/classic.header.vue +++ b/packages/frontend/src/ui/classic.header.vue @@ -45,11 +45,11 @@ import { defineAsyncComponent, defineComponent } from 'vue'; import { openInstanceMenu } from './_common_/common'; import { host } from '@/config'; -import { search } from '@/scripts/search'; import * as os from '@/os'; import { navbarItemDef } from '@/navbar'; import { openAccountMenu } from '@/account'; import MkButton from '@/components/MkButton.vue'; +import { mainRouter } from '@/router'; export default defineComponent({ components: { @@ -103,7 +103,7 @@ export default defineComponent({ }, search() { - search(); + mainRouter.push('/search'); }, more(ev) { diff --git a/packages/frontend/src/ui/classic.sidebar.vue b/packages/frontend/src/ui/classic.sidebar.vue index a11c2ba10e..6fff233ac5 100644 --- a/packages/frontend/src/ui/classic.sidebar.vue +++ b/packages/frontend/src/ui/classic.sidebar.vue @@ -44,12 +44,12 @@ import { defineAsyncComponent, defineComponent } from 'vue'; import { openInstanceMenu } from './_common_/common'; import { host } from '@/config'; -import { search } from '@/scripts/search'; import * as os from '@/os'; import { navbarItemDef } from '@/navbar'; import { openAccountMenu } from '@/account'; import MkButton from '@/components/MkButton.vue'; import { StickySidebar } from '@/scripts/sticky-sidebar'; +import { mainRouter } from '@/router'; //import MisskeyLogo from '@assets/client/misskey.svg'; export default defineComponent({ @@ -120,7 +120,7 @@ export default defineComponent({ }, search() { - search(); + mainRouter.push('/search'); }, more(ev) { diff --git a/packages/frontend/src/ui/visitor/a.vue b/packages/frontend/src/ui/visitor/a.vue index 9494b1b705..023b7fdb94 100644 --- a/packages/frontend/src/ui/visitor/a.vue +++ b/packages/frontend/src/ui/visitor/a.vue @@ -40,7 +40,6 @@ import { defineComponent } from 'vue'; import XHeader from './header.vue'; import { host, instanceName } from '@/config'; -import { search } from '@/scripts/search'; import * as os from '@/os'; import MkButton from '@/components/MkButton.vue'; import { ColdDeviceStorage } from '@/store'; @@ -77,7 +76,9 @@ export default defineComponent({ if (ColdDeviceStorage.get('syncDeviceDarkMode')) return; this.$store.set('darkMode', !this.$store.state.darkMode); }, - 's': search, + 's': () => { + mainRouter.push('/search'); + }, 'h|/': this.help, }; }, diff --git a/packages/frontend/src/ui/visitor/b.vue b/packages/frontend/src/ui/visitor/b.vue index 163f038b43..e2168768e8 100644 --- a/packages/frontend/src/ui/visitor/b.vue +++ b/packages/frontend/src/ui/visitor/b.vue @@ -58,7 +58,6 @@ import { ComputedRef, onMounted, provide } from 'vue'; import XHeader from './header.vue'; import XKanban from './kanban.vue'; import { host, instanceName } from '@/config'; -import { search } from '@/scripts/search'; import * as os from '@/os'; import { instance } from '@/instance'; import XSigninDialog from '@/components/MkSigninDialog.vue'; @@ -97,7 +96,9 @@ const keymap = $computed(() => { if (ColdDeviceStorage.get('syncDeviceDarkMode')) return; defaultStore.set('darkMode', !defaultStore.state.darkMode); }, - 's': search, + 's': () => { + mainRouter.push('/search'); + }, }; }); diff --git a/packages/frontend/src/ui/visitor/header.vue b/packages/frontend/src/ui/visitor/header.vue index 2647d0e62a..aaa7e77e90 100644 --- a/packages/frontend/src/ui/visitor/header.vue +++ b/packages/frontend/src/ui/visitor/header.vue @@ -27,7 +27,7 @@ import XSigninDialog from '@/components/MkSigninDialog.vue'; import XSignupDialog from '@/components/MkSignupDialog.vue'; import * as os from '@/os'; import { instance } from '@/instance'; -import { search } from '@/scripts/search'; +import { mainRouter } from '@/router'; export default defineComponent({ data() { @@ -55,7 +55,9 @@ export default defineComponent({ }, {}, 'closed'); }, - search, + search() { + mainRouter.push('/search'); + }, }, }); </script> |