diff --git a/CHANGELOG.md b/CHANGELOG.md index 95f435b7d7..d5ce885b9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,7 @@ - 逆に、MFMでコードハイライトを利用したい際は言語を明示的に指定する必要があります (例: ` ```js ` → Javascript, ` ```ais ` → AiScript) - Enhance: 絵文字などのオートコンプリートでShift+Tabを押すと前の候補を選択できるように +- Enhance: チャンネルに新規の投稿がある場合にバッジを表示させる - Fix: 「設定のバックアップ」で一部の項目がバックアップに含まれていなかった問題を修正 - Fix: ウィジェットのジョブキューにて音声の発音方法変更に追従できていなかったのを修正 #12367 - Fix: コードエディタが正しく表示されない問題を修正 diff --git a/packages/frontend/src/components/MkChannelPreview.vue b/packages/frontend/src/components/MkChannelPreview.vue index 4512f2dd60..bf6504d6bf 100644 --- a/packages/frontend/src/components/MkChannelPreview.vue +++ b/packages/frontend/src/components/MkChannelPreview.vue @@ -4,49 +4,70 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkA :to="`/channels/${channel.id}`" class="eftoefju _panel" tabindex="-1"> - <div class="banner" :style="bannerStyle"> - <div class="fade"></div> - <div class="name"><i class="ti ti-device-tv"></i> {{ channel.name }}</div> - <div v-if="channel.isSensitive" class="sensitiveIndicator">{{ i18n.ts.sensitive }}</div> - <div class="status"> - <div> - <i class="ti ti-users ti-fw"></i> - <I18n :src="i18n.ts._channel.usersCount" tag="span" style="margin-left: 4px;"> - <template #n> - <b>{{ channel.usersCount }}</b> - </template> - </I18n> - </div> - <div> - <i class="ti ti-pencil ti-fw"></i> - <I18n :src="i18n.ts._channel.notesCount" tag="span" style="margin-left: 4px;"> - <template #n> - <b>{{ channel.notesCount }}</b> - </template> - </I18n> +<div style="position: relative;"> + <MkA :to="`/channels/${channel.id}`" class="eftoefju _panel" tabindex="-1" @click="updateLastReadedAt"> + <div class="banner" :style="bannerStyle"> + <div class="fade"></div> + <div class="name"><i class="ti ti-device-tv"></i> {{ channel.name }}</div> + <div v-if="channel.isSensitive" class="sensitiveIndicator">{{ i18n.ts.sensitive }}</div> + <div class="status"> + <div> + <i class="ti ti-users ti-fw"></i> + <I18n :src="i18n.ts._channel.usersCount" tag="span" style="margin-left: 4px;"> + <template #n> + <b>{{ channel.usersCount }}</b> + </template> + </I18n> + </div> + <div> + <i class="ti ti-pencil ti-fw"></i> + <I18n :src="i18n.ts._channel.notesCount" tag="span" style="margin-left: 4px;"> + <template #n> + <b>{{ channel.notesCount }}</b> + </template> + </I18n> + </div> </div> </div> - </div> - <article v-if="channel.description"> - <p :title="channel.description">{{ channel.description.length > 85 ? channel.description.slice(0, 85) + '…' : channel.description }}</p> - </article> - <footer> - <span v-if="channel.lastNotedAt"> - {{ i18n.ts.updatedAt }}: <MkTime :time="channel.lastNotedAt"/> - </span> - </footer> -</MkA> + <article v-if="channel.description"> + <p :title="channel.description">{{ channel.description.length > 85 ? channel.description.slice(0, 85) + '…' : channel.description }}</p> + </article> + <footer> + <span v-if="channel.lastNotedAt"> + {{ i18n.ts.updatedAt }}: <MkTime :time="channel.lastNotedAt"/> + </span> + </footer> + </MkA> + <div + v-if="channel.lastNotedAt && (channel.isFavorited || channel.isFollowing) && (!lastReadedAt || Date.parse(channel.lastNotedAt) > lastReadedAt)" + class="indicator" + ></div> +</div> </template> <script lang="ts" setup> -import { computed } from 'vue'; +import { computed, ref, watch } from 'vue'; import { i18n } from '@/i18n.js'; +import { miLocalStorage } from '@/local-storage.js'; const props = defineProps<{ channel: Record<string, any>; }>(); +const getLastReadedAt = (): number | null => { + return miLocalStorage.getItemAsJson(`channelLastReadedAt:${props.channel.id}`) ?? null; +}; + +const lastReadedAt = ref(getLastReadedAt()); + +watch(() => props.channel.id, () => { + lastReadedAt.value = getLastReadedAt(); +}); + +const updateLastReadedAt = () => { + lastReadedAt.value = props.channel.lastNotedAt ? Date.parse(props.channel.lastNotedAt) : Date.now(); +}; + const bannerStyle = computed(() => { if (props.channel.bannerUrl) { return { backgroundImage: `url(${props.channel.bannerUrl})` }; @@ -170,4 +191,17 @@ const bannerStyle = computed(() => { } } +.indicator { + position: absolute; + top: 0; + right: 0; + transform: translate(25%, -25%); + background-color: var(--accent); + border: solid var(--bg) 4px; + border-radius: 100%; + width: 1.5rem; + height: 1.5rem; + aspect-ratio: 1 / 1; +} + </style> diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue index 8e4b86f1c7..0a97cf3dba 100644 --- a/packages/frontend/src/components/MkMenu.vue +++ b/packages/frontend/src/components/MkMenu.vue @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template v-for="(item, i) in items2"> <div v-if="item.type === 'divider'" role="separator" :class="$style.divider"></div> <span v-else-if="item.type === 'label'" role="menuitem" :class="[$style.label, $style.item]"> - <span>{{ item.text }}</span> + <span style="opacity: 0.7;">{{ item.text }}</span> </span> <span v-else-if="item.type === 'pending'" role="menuitem" :tabindex="i" :class="[$style.pending, $style.item]"> <span><MkEllipsis/></span> @@ -23,32 +23,44 @@ SPDX-License-Identifier: AGPL-3.0-only <MkA v-else-if="item.type === 'link'" role="menuitem" :to="item.to" :tabindex="i" class="_button" :class="$style.item" @click.passive="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i> <MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/> - <span>{{ item.text }}</span> - <span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span> + <div :class="$style.item_content"> + <span :class="$style.item_content_text">{{ item.text }}</span> + <span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span> + </div> </MkA> <a v-else-if="item.type === 'a'" role="menuitem" :href="item.href" :target="item.target" :download="item.download" :tabindex="i" class="_button" :class="$style.item" @click="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i> - <span>{{ item.text }}</span> - <span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span> + <div :class="$style.item_content"> + <span :class="$style.item_content_text">{{ item.text }}</span> + <span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span> + </div> </a> <button v-else-if="item.type === 'user'" role="menuitem" :tabindex="i" class="_button" :class="[$style.item, { [$style.active]: item.active }]" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> <MkAvatar :user="item.user" :class="$style.avatar"/><MkUserName :user="item.user"/> - <span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span> + <div v-if="item.indicate" :class="$style.item_content"> + <span :class="$style.indicator"><i class="_indicatorCircle"></i></span> + </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)"/> - <span :class="$style.switchText">{{ item.text }}</span> + <div :class="$style.item_content"> + <span :class="[$style.item_content_text, $style.switchText]">{{ 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)"> <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]" style="pointer-events: none;"></i> - <span style="pointer-events: none;">{{ item.text }}</span> - <span :class="$style.caret" style="pointer-events: none;"><i class="ti ti-chevron-right ti-fw"></i></span> + <div :class="$style.item_content"> + <span :class="$style.item_content_text" style="pointer-events: none;">{{ item.text }}</span> + <span :class="$style.caret" style="pointer-events: none;"><i class="ti ti-chevron-right ti-fw"></i></span> + </div> </button> <button v-else :tabindex="i" class="_button" role="menuitem" :class="[$style.item, { [$style.danger]: item.danger, [$style.active]: item.active }]" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i> <MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/> - <span>{{ item.text }}</span> - <span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span> + <div :class="$style.item_content"> + <span :class="$style.item_content_text">{{ item.text }}</span> + <span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span> + </div> </button> </template> <span v-if="items2.length === 0" :class="[$style.none, $style.item]"> @@ -228,6 +240,7 @@ onBeforeUnmount(() => { .root { padding: 8px 0; box-sizing: border-box; + max-width: 100vw; min-width: 200px; overflow: auto; overscroll-behavior: contain; @@ -267,7 +280,8 @@ onBeforeUnmount(() => { } .item { - display: block; + display: flex; + align-items: center; position: relative; padding: 5px 16px; width: 100%; @@ -340,10 +354,6 @@ onBeforeUnmount(() => { pointer-events: none; font-size: 0.7em; padding-bottom: 4px; - - > span { - opacity: 0.7; - } } &.pending { @@ -373,6 +383,22 @@ onBeforeUnmount(() => { } } +.item_content { + width: 100%; + max-width: 100vw; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + text-overflow: ellipsis; +} + +.item_content_text { + max-width: calc(100vw - 4rem); + text-overflow: ellipsis; + overflow: hidden; +} + .switch { position: relative; display: flex; @@ -406,6 +432,7 @@ onBeforeUnmount(() => { .icon { margin-right: 8px; + line-height: 1; } .caret { @@ -419,9 +446,8 @@ onBeforeUnmount(() => { } .indicator { - position: absolute; - top: 5px; - left: 13px; + display: flex; + align-items: center; color: var(--indicator); font-size: 12px; animation: blink 1s infinite; diff --git a/packages/frontend/src/local-storage.ts b/packages/frontend/src/local-storage.ts index 0d73885b68..1ef115978e 100644 --- a/packages/frontend/src/local-storage.ts +++ b/packages/frontend/src/local-storage.ts @@ -35,7 +35,8 @@ type Keys = `themes:${string}` | `aiscript:${string}` | 'lastEmojisFetchedAt' | // DEPRECATED, stored in indexeddb (13.9.0~) - 'emojis' // DEPRECATED, stored in indexeddb (13.9.0~); + 'emojis' | // DEPRECATED, stored in indexeddb (13.9.0~); + `channelLastReadedAt:${string}` export const miLocalStorage = { getItem: (key: Keys): string | null => window.localStorage.getItem(key), diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue index 698f7fa383..421895ea6c 100644 --- a/packages/frontend/src/pages/channel.vue +++ b/packages/frontend/src/pages/channel.vue @@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only <XChannelFollowButton :channel="channel" :full="true" :class="$style.subscribe"/> <MkButton v-if="favorited" v-tooltip="i18n.ts.unfavorite" asLike class="button" rounded primary :class="$style.favorite" @click="unfavorite()"><i class="ti ti-star"></i></MkButton> <MkButton v-else v-tooltip="i18n.ts.favorite" asLike class="button" rounded :class="$style.favorite" @click="favorite()"><i class="ti ti-star"></i></MkButton> - <div :style="{ backgroundImage: channel.bannerUrl ? `url(${channel.bannerUrl})` : null }" :class="$style.banner"> + <div :style="{ backgroundImage: channel.bannerUrl ? `url(${channel.bannerUrl})` : undefined }" :class="$style.banner"> <div :class="$style.bannerStatus"> <div><i class="ti ti-users ti-fw"></i><I18n :src="i18n.ts._channel.usersCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.usersCount }}</b></template></I18n></div> <div><i class="ti ti-pencil ti-fw"></i><I18n :src="i18n.ts._channel.notesCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.notesCount }}</b></template></I18n></div> @@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkFoldableSection> <template #header><i class="ti ti-pin ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.pinnedNotes }}</template> - <div v-if="channel.pinnedNotes.length > 0" class="_gaps"> + <div v-if="channel.pinnedNotes && channel.pinnedNotes.length > 0" class="_gaps"> <MkNote v-for="note in channel.pinnedNotes" :key="note.id" class="_panel" :note="note"/> </div> </MkFoldableSection> @@ -38,7 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only <!-- スマホ・タブレットの場合、キーボードが表示されると投稿が見づらくなるので、デスクトップ場合のみ自動でフォーカスを当てる --> <MkPostForm v-if="$i && defaultStore.reactiveState.showFixedPostFormInChannel.value" :channel="channel" class="post-form _panel" fixed :autofocus="deviceKind === 'desktop'"/> - <MkTimeline :key="channelId" src="channel" :channel="channelId" @before="before" @after="after"/> + <MkTimeline :key="channelId" src="channel" :channel="channelId" @before="before" @after="after" @note="miLocalStorage.setItemAsJson(`channelLastReadedAt:${channel.id}`, Date.now())"/> </div> <div v-else-if="tab === 'featured'"> <MkNotes :pagination="featuredPagination"/> @@ -69,6 +69,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, watch, ref } from 'vue'; +import * as Misskey from 'misskey-js'; import MkPostForm from '@/components/MkPostForm.vue'; import MkTimeline from '@/components/MkTimeline.vue'; import XChannelFollowButton from '@/components/MkChannelFollowButton.vue'; @@ -89,6 +90,7 @@ import MkFoldableSection from '@/components/MkFoldableSection.vue'; import { PageHeaderItem } from '@/types/page-header.js'; import { isSupportShare } from '@/scripts/navigator.js'; import copyToClipboard from '@/scripts/copy-to-clipboard.js'; +import { miLocalStorage } from '@/local-storage.js'; const router = useRouter(); @@ -97,7 +99,7 @@ const props = defineProps<{ }>(); const tab = ref('overview'); -const channel = ref(null); +const channel = ref<Misskey.entities.Channel | null>(null); const favorited = ref(false); const searchQuery = ref(''); const searchPagination = ref(); @@ -114,14 +116,23 @@ watch(() => props.channelId, async () => { channel.value = await os.api('channels/show', { channelId: props.channelId, }); - favorited.value = channel.value.isFavorited; + favorited.value = channel.value.isFavorited ?? false; if (favorited.value || channel.value.isFollowing) { tab.value = 'timeline'; } + + if ((favorited.value || channel.value.isFollowing) && channel.value.lastNotedAt) { + const lastReadedAt: number = miLocalStorage.getItemAsJson(`channelLastReadedAt:${channel.value.id}`) ?? 0; + const lastNotedAt = Date.parse(channel.value.lastNotedAt); + + if (lastNotedAt > lastReadedAt) { + miLocalStorage.setItemAsJson(`channelLastReadedAt:${channel.value.id}`, lastNotedAt); + } + } }, { immediate: true }); function edit() { - router.push(`/channels/${channel.value.id}/edit`); + router.push(`/channels/${channel.value?.id}/edit`); } function openPostForm() { @@ -131,6 +142,8 @@ function openPostForm() { } function favorite() { + if (!channel.value) return; + os.apiWithDialog('channels/favorite', { channelId: channel.value.id, }).then(() => { @@ -139,6 +152,8 @@ function favorite() { } async function unfavorite() { + if (!channel.value) return; + const confirm = await os.confirm({ type: 'warning', text: i18n.ts.unfavoriteConfirm, @@ -152,6 +167,8 @@ async function unfavorite() { } async function search() { + if (!channel.value) return; + const query = searchQuery.value.toString().trim(); if (query == null) return; @@ -176,6 +193,10 @@ const headerActions = computed(() => { icon: 'ti ti-link', text: i18n.ts.copyUrl, handler: async (): Promise<void> => { + if (!channel.value) { + console.warn('failed to copy channel URL. channel.value is null.'); + return; + } copyToClipboard(`${url}/channels/${channel.value.id}`); os.success(); }, @@ -186,9 +207,14 @@ const headerActions = computed(() => { icon: 'ti ti-share', text: i18n.ts.share, handler: async (): Promise<void> => { + if (!channel.value) { + console.warn('failed to share channel. channel.value is null.'); + return; + } + navigator.share({ title: channel.value.name, - text: channel.value.description, + text: channel.value.description ?? undefined, url: `${url}/channels/${channel.value.id}`, }); }, diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue index d976463db4..1b24f98bdb 100644 --- a/packages/frontend/src/pages/timeline.vue +++ b/packages/frontend/src/pages/timeline.vue @@ -48,6 +48,7 @@ import { definePageMetadata } from '@/scripts/page-metadata.js'; import { antennasCache, userListsCache } from '@/cache.js'; import { deviceKind } from '@/scripts/device-kind.js'; import { MenuItem } from '@/types/menu.js'; +import { miLocalStorage } from '@/local-storage.js'; provide('shouldOmitHeaderTitle', true); @@ -125,12 +126,17 @@ async function chooseChannel(ev: MouseEvent): Promise<void> { limit: 100, }); const items: MenuItem[] = [ - ...channels.map(channel => ({ - type: 'link' as const, - text: channel.name, - indicate: channel.hasUnreadNote, - to: `/channels/${channel.id}`, - })), + ...channels.map(channel => { + const lastReadedAt = miLocalStorage.getItemAsJson(`channelLastReadedAt:${channel.id}`) ?? null; + const hasUnreadNote = (lastReadedAt && channel.lastNotedAt) ? Date.parse(channel.lastNotedAt) > lastReadedAt : !!(!lastReadedAt && channel.lastNotedAt); + + return { + type: 'link' as const, + text: channel.name, + indicate: hasUnreadNote, + to: `/channels/${channel.id}`, + }; + }), (channels.length === 0 ? undefined : { type: 'divider' }), { type: 'link' as const,