diff --git a/CHANGELOG.md b/CHANGELOG.md index 959954b661..a03ca4ab86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ - フォルダーやファイルに対しても開発者モード使用時、IDをコピーできるように - 引用対象を「もっと見る」で展開した場合、「閉じる」で畳めるように - プロフィールURLをコピーできるボタンを追加 #11190 +- ユーザーのContextMenuに「アンテナに追加」ボタンを追加 - フォローやお気に入り登録をしていないチャンネルを開く時は概要ページを開くように - 画面ビューワをタップした場合、マウスクリックと同様に画像ビューワを閉じるように - Fix: サーバーメトリクスが90度傾いている diff --git a/locales/index.d.ts b/locales/index.d.ts index 42dddebbff..8697b22e21 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -52,6 +52,7 @@ export interface Locale { "deleteAndEdit": string; "deleteAndEditConfirm": string; "addToList": string; + "addToAntenna": string; "sendMessage": string; "copyRSS": string; "copyUsername": string; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 16014da511..82efc8a469 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -49,6 +49,7 @@ delete: "削除" deleteAndEdit: "削除して編集" deleteAndEditConfirm: "このノートを削除してもう一度編集しますか?このノートへのリアクション、Renote、返信も全て削除されます。" addToList: "リストに追加" +addToAntenna: "アンテナに追加" sendMessage: "メッセージを送信" copyRSS: "RSSをコピー" copyUsername: "ユーザー名をコピー" diff --git a/packages/frontend/src/cache.ts b/packages/frontend/src/cache.ts index c95da64bba..cb315c8ff7 100644 --- a/packages/frontend/src/cache.ts +++ b/packages/frontend/src/cache.ts @@ -4,3 +4,4 @@ import { Cache } from '@/scripts/cache'; export const clipsCache = new Cache<misskey.entities.Clip[]>(Infinity); export const rolesCache = new Cache(Infinity); export const userListsCache = new Cache<misskey.entities.UserList[]>(Infinity); +export const antennasCache = new Cache<misskey.entities.Antenna[]>(Infinity); diff --git a/packages/frontend/src/components/MkAvatars.vue b/packages/frontend/src/components/MkAvatars.vue index 630620fc08..437dce0a14 100644 --- a/packages/frontend/src/components/MkAvatars.vue +++ b/packages/frontend/src/components/MkAvatars.vue @@ -1,24 +1,29 @@ <template> <div> - <div v-for="user in users" :key="user.id" style="display:inline-block;width:32px;height:32px;margin-right:8px;"> + <div v-for="user in users.slice(0, limit)" :key="user.id" style="display:inline-block;width:32px;height:32px;margin-right:8px;"> <MkAvatar :user="user" style="width:32px; height:32px;" indicator link preview/> </div> + <div v-if="users.length > limit" style="display: inline-block;">...</div> </div> </template> <script lang="ts" setup> import { onMounted, ref } from 'vue'; import * as os from '@/os'; +import { UserLite } from 'misskey-js/built/entities'; -const props = defineProps<{ +const props = withDefaults(defineProps<{ userIds: string[]; -}>(); + limit?: number; +}>(), { + limit: Infinity, +}); -const users = ref([]); +const users = ref<UserLite[]>([]); onMounted(async () => { users.value = await os.api('users/show', { userIds: props.userIds, - }); + }) as unknown as UserLite[]; }); </script> diff --git a/packages/frontend/src/pages/my-antennas/create.vue b/packages/frontend/src/pages/my-antennas/create.vue index 355d18fdb5..632c36bbf8 100644 --- a/packages/frontend/src/pages/my-antennas/create.vue +++ b/packages/frontend/src/pages/my-antennas/create.vue @@ -9,6 +9,7 @@ import XAntenna from './editor.vue'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; import { useRouter } from '@/router'; +import { antennasCache } from '@/cache'; const router = useRouter(); @@ -26,13 +27,10 @@ let draft = $ref({ }); function onAntennaCreated() { + antennasCache.delete(); router.push('/my/antennas'); } -const headerActions = $computed(() => []); - -const headerTabs = $computed(() => []); - definePageMetadata({ title: i18n.ts.manageAntennas, icon: 'ti ti-antenna', diff --git a/packages/frontend/src/pages/my-antennas/edit.vue b/packages/frontend/src/pages/my-antennas/edit.vue index da9b2de48f..3fb9690ac1 100644 --- a/packages/frontend/src/pages/my-antennas/edit.vue +++ b/packages/frontend/src/pages/my-antennas/edit.vue @@ -10,6 +10,7 @@ import * as os from '@/os'; import { i18n } from '@/i18n'; import { useRouter } from '@/router'; import { definePageMetadata } from '@/scripts/page-metadata'; +import { antennasCache } from '@/cache'; const router = useRouter(); @@ -20,6 +21,7 @@ const props = defineProps<{ }>(); function onAntennaUpdated() { + antennasCache.delete(); router.push('/my/antennas'); } @@ -27,10 +29,6 @@ os.api('antennas/show', { antennaId: props.antennaId }).then((antennaResponse) = antenna = antennaResponse; }); -const headerActions = $computed(() => []); - -const headerTabs = $computed(() => []); - definePageMetadata({ title: i18n.ts.manageAntennas, icon: 'ti ti-antenna', diff --git a/packages/frontend/src/pages/my-antennas/index.vue b/packages/frontend/src/pages/my-antennas/index.vue index 2ca026b9a1..1e9136f1fa 100644 --- a/packages/frontend/src/pages/my-antennas/index.vue +++ b/packages/frontend/src/pages/my-antennas/index.vue @@ -2,15 +2,20 @@ <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :contentMax="700"> - <div class="ieepwinx"> - <MkButton :link="true" to="/my/antennas/create" primary class="add"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> + <div> + <div v-if="antennas.length === 0" class="empty"> + <div class="_fullinfo"> + <img :src="infoImageUrl" class="_ghost"/> + <div>{{ i18n.ts.nothing }}</div> + </div> + </div> - <div class=""> - <MkPagination v-slot="{items}" ref="list" :pagination="pagination"> - <MkA v-for="antenna in items" :key="antenna.id" class="ljoevbzj" :to="`/my/antennas/${antenna.id}`"> - <div class="name">{{ antenna.name }}</div> - </MkA> - </MkPagination> + <MkButton :link="true" to="/my/antennas/create" primary :class="$style.add"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> + + <div v-if="antennas.length > 0" class="_gaps"> + <MkA v-for="antenna in antennas" :key="antenna.id" :class="$style.antenna" :to="`/my/antennas/${antenna.id}`"> + <div class="name">{{ antenna.name }}</div> + </MkA> </div> </div> </MkSpacer> @@ -18,19 +23,31 @@ </template> <script lang="ts" setup> -import { } from 'vue'; -import MkPagination from '@/components/MkPagination.vue'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; +import { antennasCache } from '@/cache'; +import { api } from '@/os'; +import { onActivated } from 'vue'; +import { infoImageUrl } from '@/instance'; -const pagination = { - endpoint: 'antennas/list' as const, - noPaging: true, - limit: 10, -}; +const antennas = $computed(() => antennasCache.value.value ?? []); -const headerActions = $computed(() => []); +function fetch() { + antennasCache.fetch(() => api('antennas/list')); +} + +fetch(); + +const headerActions = $computed(() => [{ + asFullButton: true, + icon: 'ti ti-refresh', + text: i18n.ts.reload, + handler: () => { + antennasCache.delete(); + fetch(); + }, +}]); const headerTabs = $computed(() => []); @@ -38,30 +55,30 @@ definePageMetadata({ title: i18n.ts.manageAntennas, icon: 'ti ti-antenna', }); + +onActivated(() => { + antennasCache.fetch(() => api('antennas/list')); +}); </script> -<style lang="scss" scoped> -.ieepwinx { +<style lang="scss" module> +.add { + margin: 0 auto 16px auto; +} - > .add { - margin: 0 auto 16px auto; - } +.antenna { + display: block; + padding: 16px; + border: solid 1px var(--divider); + border-radius: 6px; - .ljoevbzj { - display: block; - padding: 16px; - margin-bottom: 8px; - border: solid 1px var(--divider); - border-radius: 6px; - - &:hover { - border: solid 1px var(--accent); - text-decoration: none; - } - - > .name { - font-weight: bold; - } + &:hover { + border: solid 1px var(--accent); + text-decoration: none; } } + +.name { + font-weight: bold; +} </style> diff --git a/packages/frontend/src/pages/my-lists/index.vue b/packages/frontend/src/pages/my-lists/index.vue index cee241c489..0f59ca0b36 100644 --- a/packages/frontend/src/pages/my-lists/index.vue +++ b/packages/frontend/src/pages/my-lists/index.vue @@ -3,38 +3,43 @@ <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :contentMax="700"> <div class="_gaps"> + <div v-if="items.length === 0" class="empty"> + <div class="_fullinfo"> + <img :src="infoImageUrl" class="_ghost"/> + <div>{{ i18n.ts.nothing }}</div> + </div> + </div> + <MkButton primary rounded style="margin: 0 auto;" @click="create"><i class="ti ti-plus"></i> {{ i18n.ts.createList }}</MkButton> - <MkPagination v-slot="{items}" ref="pagingComponent" :pagination="pagination"> - <div class="_gaps"> - <MkA v-for="list in items" :key="list.id" class="_panel" :class="$style.list" :to="`/my/lists/${ list.id }`"> - <div style="margin-bottom: 4px;">{{ list.name }}</div> - <MkAvatars :userIds="list.userIds"/> - </MkA> - </div> - </MkPagination> + <div v-if="items.length > 0" class="_gaps"> + <MkA v-for="list in items" :key="list.id" class="_panel" :class="$style.list" :to="`/my/lists/${ list.id }`"> + <div style="margin-bottom: 4px;">{{ list.name }}</div> + <MkAvatars :userIds="list.userIds" :limit="10"/> + </MkA> + </div> </div> </MkSpacer> </MkStickyContainer> </template> <script lang="ts" setup> -import { } from 'vue'; -import MkPagination from '@/components/MkPagination.vue'; +import { onActivated } from 'vue'; import MkButton from '@/components/MkButton.vue'; import MkAvatars from '@/components/MkAvatars.vue'; import * as os from '@/os'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; import { userListsCache } from '@/cache'; +import { infoImageUrl } from '@/instance'; -const pagingComponent = $shallowRef<InstanceType<typeof MkPagination>>(); +const items = $computed(() => userListsCache.value.value ?? []); -const pagination = { - endpoint: 'users/lists/list' as const, - noPaging: true, - limit: 10, -}; +function fetch() { + userListsCache.fetch(() => os.api('users/lists/list')); +} + +fetch(); async function create() { const { canceled, result: name } = await os.inputText({ @@ -43,10 +48,18 @@ async function create() { if (canceled) return; await os.apiWithDialog('users/lists/create', { name: name }); userListsCache.delete(); - pagingComponent.reload(); + fetch(); } -const headerActions = $computed(() => []); +const headerActions = $computed(() => [{ + asFullButton: true, + icon: 'ti ti-refresh', + text: i18n.ts.reload, + handler: () => { + userListsCache.delete(); + fetch(); + }, +}]); const headerTabs = $computed(() => []); @@ -58,6 +71,10 @@ definePageMetadata({ handler: create, }, }); + +onActivated(() => { + fetch(); +}); </script> <style lang="scss" module> diff --git a/packages/frontend/src/scripts/cache.ts b/packages/frontend/src/scripts/cache.ts index 858e5f03bf..a61d858353 100644 --- a/packages/frontend/src/scripts/cache.ts +++ b/packages/frontend/src/scripts/cache.ts @@ -1,7 +1,8 @@ +import { ref } from "vue"; export class Cache<T> { private cachedAt: number | null = null; - private value: T | undefined; + public value = ref<T | undefined>(); private lifetime: number; constructor(lifetime: Cache<never>['lifetime']) { @@ -10,21 +11,20 @@ export class Cache<T> { public set(value: T): void { this.cachedAt = Date.now(); - this.value = value; + this.value.value = value; } - public get(): T | undefined { + private get(): T | undefined { if (this.cachedAt == null) return undefined; if ((Date.now() - this.cachedAt) > this.lifetime) { - this.value = undefined; + this.value.value = undefined; this.cachedAt = null; return undefined; } - return this.value; + return this.value.value; } public delete() { - this.value = undefined; this.cachedAt = null; } diff --git a/packages/frontend/src/scripts/get-user-menu.ts b/packages/frontend/src/scripts/get-user-menu.ts index bdcd1aabdc..1c93d58b44 100644 --- a/packages/frontend/src/scripts/get-user-menu.ts +++ b/packages/frontend/src/scripts/get-user-menu.ts @@ -1,3 +1,4 @@ +import { toUnicode } from 'punycode'; import { defineAsyncComponent } from 'vue'; import * as misskey from 'misskey-js'; import { i18n } from '@/i18n'; @@ -8,8 +9,7 @@ import { defaultStore, userActions } from '@/store'; import { $i, iAmModerator } from '@/account'; import { mainRouter } from '@/router'; import { Router } from '@/nirax'; -import { rolesCache, userListsCache } from '@/cache'; -import { toUnicode } from 'punycode'; +import { antennasCache, rolesCache, userListsCache } from '@/cache'; export function getUserMenu(user: misskey.entities.UserDetailed, router: Router = mainRouter) { const meId = $i ? $i.id : null; @@ -166,11 +166,39 @@ export function getUserMenu(user: misskey.entities.UserDetailed, router: Router return lists.map(list => ({ text: list.name, - action: () => { - os.apiWithDialog('users/lists/push', { + action: async () => { + await os.apiWithDialog('users/lists/push', { listId: list.id, userId: user.id, }); + userListsCache.delete(); + }, + })); + }, + }, { + type: 'parent', + icon: 'ti ti-antenna', + text: i18n.ts.addToAntenna, + children: async () => { + const antennas = await antennasCache.fetch(() => os.api('antennas/list')); + const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${toUnicode(user.host)}`; + return antennas.filter((a) => a.src === 'users').map(antenna => ({ + text: antenna.name, + action: async () => { + await os.apiWithDialog('antennas/update', { + antennaId: antenna.id, + name: antenna.name, + keywords: antenna.keywords, + excludeKeywords: antenna.excludeKeywords, + src: antenna.src, + userListId: antenna.userListId, + users: [...antenna.users, canonical], + caseSensitive: antenna.caseSensitive, + withReplies: antenna.withReplies, + withFile: antenna.withFile, + notify: antenna.notify, + }); + antennasCache.delete(); }, })); },