diff options
Diffstat (limited to 'packages/frontend/src')
17 files changed, 233 insertions, 128 deletions
diff --git a/packages/frontend/src/components/MkContainer.vue b/packages/frontend/src/components/MkContainer.vue index 5b79ac6699..3362139c3d 100644 --- a/packages/frontend/src/components/MkContainer.vue +++ b/packages/frontend/src/components/MkContainer.vue @@ -216,6 +216,14 @@ onUnmounted(() => { .content { --MI-stickyTop: 0px; + /* + 理屈は知らないけど、ここでbackgroundを設定しておかないと + スクロールコンテナーが少なくともChromeにおいて + main thread scrolling になってしまい、パフォーマンスが(多分)落ちる。 + backgroundが透明だと裏側を描画しないといけなくなるとかそういう理由かもしれない + */ + background: var(--MI_THEME-panel); + &.omitted { position: relative; max-height: var(--maxHeight); diff --git a/packages/frontend/src/components/MkDateSeparatedList.vue b/packages/frontend/src/components/MkDateSeparatedList.vue index 857b7e3f92..63e6b74154 100644 --- a/packages/frontend/src/components/MkDateSeparatedList.vue +++ b/packages/frontend/src/components/MkDateSeparatedList.vue @@ -3,16 +3,18 @@ SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> +<!-- TODO: 親からスタイルを当てにくいことや実装がトリッキーなことを鑑み廃止または使用の縮小(timeline-date-separate.tsを使う) --> + <script lang="ts"> import { defineComponent, h, TransitionGroup, useCssModule } from 'vue'; import type { PropType } from 'vue'; import type { MisskeyEntity } from '@/types/date-separated-list.js'; import MkAd from '@/components/global/MkAd.vue'; import { isDebuggerEnabled, stackTraceInstances } from '@/debug.js'; -import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; import { instance } from '@/instance.js'; import { prefer } from '@/preferences.js'; +import { getDateText } from '@/utility/timeline-date-separate.js'; import { $i } from '@/i.js'; export default defineComponent({ @@ -46,15 +48,6 @@ export default defineComponent({ setup(props, { slots, expose }) { const $style = useCssModule(); // カスタムレンダラなので使っても大丈夫 - function getDateText(dateInstance: Date) { - const date = dateInstance.getDate(); - const month = dateInstance.getMonth() + 1; - return i18n.tsx.monthAndDay({ - month: month.toString(), - day: date.toString(), - }); - } - if (props.items.length === 0) return; const renderChildrenImpl = (shouldHideAds: boolean) => props.items.map((item, i) => { diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index f6c24c13da..d04996dac0 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -52,6 +52,11 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkFolder> + <MkSwitch v-model="role.preserveAssignmentOnMoveAccount" :readonly="readonly"> + <template #label>{{ i18n.ts._role.preserveAssignmentOnMoveAccount }}</template> + <template #caption>{{ i18n.ts._role.preserveAssignmentOnMoveAccount_description }}</template> + </MkSwitch> + <MkSwitch v-model="role.canEditMembersByModerator" :readonly="readonly"> <template #label>{{ i18n.ts._role.canEditMembersByModerator }}</template> <template #caption>{{ i18n.ts._role.descriptionOfCanEditMembersByModerator }}</template> diff --git a/packages/frontend/src/pages/chat/XMessage.vue b/packages/frontend/src/pages/chat/XMessage.vue index 8f7e9dd986..7524283641 100644 --- a/packages/frontend/src/pages/chat/XMessage.vue +++ b/packages/frontend/src/pages/chat/XMessage.vue @@ -5,33 +5,28 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="[$style.root, { [$style.isMe]: isMe }]"> - <MkAvatar :class="$style.avatar" :user="message.fromUser" :link="!isMe" :preview="false"/> + <MkAvatar :class="$style.avatar" :user="message.fromUser!" :link="!isMe" :preview="false"/> <div :class="$style.body" @contextmenu.stop="onContextmenu"> - <div :class="$style.header"><MkUserName v-if="!isMe && prefer.s['chat.showSenderName']" :user="message.fromUser"/></div> + <div :class="$style.header"><MkUserName v-if="!isMe && prefer.s['chat.showSenderName'] && message.fromUser != null" :user="message.fromUser"/></div> <MkFukidashi :class="$style.fukidashi" :tail="isMe ? 'right' : 'left'" :accented="isMe"> - <div v-if="!message.isDeleted" :class="$style.content"> - <Mfm - v-if="message.text" - ref="text" - class="_selectable" - :text="message.text" - :i="$i" - :nyaize="'respect'" - :enableEmojiMenu="true" - :enableEmojiMenuReaction="true" - /> - <MkMediaList v-if="message.file" :mediaList="[message.file]" :class="$style.file"/> - </div> - <div v-else :class="$style.content"> - <p>{{ i18n.ts.deleted }}</p> - </div> + <Mfm + v-if="message.text" + ref="text" + class="_selectable" + :text="message.text" + :i="$i" + :nyaize="'respect'" + :enableEmojiMenu="true" + :enableEmojiMenuReaction="true" + /> + <MkMediaList v-if="message.file" :mediaList="[message.file]" :class="$style.file"/> </MkFukidashi> <MkUrlPreview v-for="url in urls" :key="url" :url="url" style="margin: 8px 0;"/> <div :class="$style.footer"> <button class="_textButton" style="color: currentColor;" @click="showMenu"><i class="ti ti-dots-circle-horizontal"></i></button> <MkTime :class="$style.time" :time="message.createdAt"/> - <MkA v-if="isSearchResult && message.toRoomId" :to="`/chat/room/${message.toRoomId}`">{{ message.toRoom.name }}</MkA> - <MkA v-if="isSearchResult && message.toUserId && isMe" :to="`/chat/user/${message.toUserId}`">@{{ message.toUser.username }}</MkA> + <MkA v-if="isSearchResult && 'toRoom' in message && message.toRoom != null" :to="`/chat/room/${message.toRoomId}`">{{ message.toRoom.name }}</MkA> + <MkA v-if="isSearchResult && 'toUser' in message && message.toUser != null && isMe" :to="`/chat/user/${message.toUserId}`">@{{ message.toUser.username }}</MkA> </div> <TransitionGroup :enterActiveClass="prefer.s.animation ? $style.transition_reaction_enterActive : ''" @@ -62,6 +57,7 @@ import * as Misskey from 'misskey-js'; import { url } from '@@/js/config.js'; import { isLink } from '@@/js/is-link.js'; import type { MenuItem } from '@/types/menu.js'; +import type { NormalizedChatMessage } from './room.vue'; import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js'; import MkUrlPreview from '@/components/MkUrlPreview.vue'; import { ensureSignin } from '@/i.js'; @@ -76,11 +72,12 @@ import * as sound from '@/utility/sound.js'; import MkReactionIcon from '@/components/MkReactionIcon.vue'; import { prefer } from '@/preferences.js'; import { DI } from '@/di.js'; +import { getHTMLElementOrNull } from '@/utility/get-dom-node-or-null.js'; const $i = ensureSignin(); const props = defineProps<{ - message: Misskey.entities.ChatMessageLite | Misskey.entities.ChatMessage; + message: NormalizedChatMessage | Misskey.entities.ChatMessage; isSearchResult?: boolean; }>(); @@ -88,6 +85,8 @@ const isMe = computed(() => props.message.fromUserId === $i.id); const urls = computed(() => props.message.text ? extractUrlFromMfm(mfm.parse(props.message.text)) : []); provide(DI.mfmEmojiReactCallback, (reaction) => { + if (!$i.policies.canChat) return; + sound.playMisskeySfx('reaction'); misskeyApi('chat/messages/react', { messageId: props.message.id, @@ -96,7 +95,12 @@ provide(DI.mfmEmojiReactCallback, (reaction) => { }); function react(ev: MouseEvent) { - reactionPicker.show(ev.currentTarget ?? ev.target, null, async (reaction) => { + if (!$i.policies.canChat) return; + + const targetEl = getHTMLElementOrNull(ev.currentTarget ?? ev.target); + if (!targetEl) return; + + reactionPicker.show(targetEl, null, async (reaction) => { sound.playMisskeySfx('reaction'); misskeyApi('chat/messages/react', { messageId: props.message.id, @@ -106,6 +110,8 @@ function react(ev: MouseEvent) { } function onReactionClick(record: Misskey.entities.ChatMessage['reactions'][0]) { + if (!$i.policies.canChat) return; + if (record.user.id === $i.id) { misskeyApi('chat/messages/unreact', { messageId: props.message.id, @@ -132,7 +138,7 @@ function onContextmenu(ev: MouseEvent) { function showMenu(ev: MouseEvent, contextmenu = false) { const menu: MenuItem[] = []; - if (!isMe.value) { + if (!isMe.value && $i.policies.canChat) { menu.push({ text: i18n.ts.reaction, icon: 'ti ti-mood-plus', @@ -150,7 +156,7 @@ function showMenu(ev: MouseEvent, contextmenu = false) { text: i18n.ts.copyContent, icon: 'ti ti-copy', action: () => { - copyToClipboard(props.message.text); + copyToClipboard(props.message.text ?? ''); }, }); @@ -158,7 +164,7 @@ function showMenu(ev: MouseEvent, contextmenu = false) { type: 'divider', }); - if (isMe.value) { + if (isMe.value && $i.policies.canChat) { menu.push({ text: i18n.ts.delete, icon: 'ti ti-trash', @@ -169,14 +175,16 @@ function showMenu(ev: MouseEvent, contextmenu = false) { }); }, }); - } else { + } + + if (!isMe.value && props.message.fromUser != null) { menu.push({ text: i18n.ts.reportAbuse, icon: 'ti ti-exclamation-circle', action: () => { const localUrl = `${url}/chat/messages/${props.message.id}`; const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), { - user: props.message.fromUser, + user: props.message.fromUser!, initialComment: `${localUrl}\n-----\n`, }, { closed: () => dispose(), diff --git a/packages/frontend/src/pages/chat/home.home.vue b/packages/frontend/src/pages/chat/home.home.vue index 105f5f7989..17f0e0fbcd 100644 --- a/packages/frontend/src/pages/chat/home.home.vue +++ b/packages/frontend/src/pages/chat/home.home.vue @@ -67,7 +67,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, onActivated, onDeactivated, onMounted, ref } from 'vue'; +import { onActivated, onDeactivated, onMounted, ref } from 'vue'; import * as Misskey from 'misskey-js'; import { useInterval } from '@@/js/use-interval.js'; import XMessage from './XMessage.vue'; @@ -163,7 +163,7 @@ async function fetchHistory() { .map(m => ({ id: m.id, message: m, - other: m.room == null ? (m.fromUserId === $i.id ? m.toUser : m.fromUser) : null, + other: (!('room' in m) || m.room == null) ? (m.fromUserId === $i.id ? m.toUser : m.fromUser) : null, isMe: m.fromUserId === $i.id, })); diff --git a/packages/frontend/src/pages/chat/home.invitations.vue b/packages/frontend/src/pages/chat/home.invitations.vue index 4c3c0b282e..82b22ea9dd 100644 --- a/packages/frontend/src/pages/chat/home.invitations.vue +++ b/packages/frontend/src/pages/chat/home.invitations.vue @@ -35,18 +35,14 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, onMounted, ref } from 'vue'; +import { onMounted, ref } from 'vue'; import * as Misskey from 'misskey-js'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; import { misskeyApi } from '@/utility/misskey-api.js'; -import { ensureSignin } from '@/i.js'; import { useRouter } from '@/router.js'; -import * as os from '@/os.js'; import MkFolder from '@/components/MkFolder.vue'; -const $i = ensureSignin(); - const router = useRouter(); const fetching = ref(true); @@ -55,8 +51,7 @@ const invitations = ref<Misskey.entities.ChatRoomInvitation[]>([]); async function fetchInvitations() { fetching.value = true; - const res = await misskeyApi('chat/rooms/invitations/inbox', { - }); + const res = await misskeyApi('chat/rooms/invitations/inbox'); invitations.value = res; diff --git a/packages/frontend/src/pages/chat/home.joiningRooms.vue b/packages/frontend/src/pages/chat/home.joiningRooms.vue index 63e4d2adf8..f9fd6bfd55 100644 --- a/packages/frontend/src/pages/chat/home.joiningRooms.vue +++ b/packages/frontend/src/pages/chat/home.joiningRooms.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div class="_gaps"> <div v-if="memberships.length > 0" class="_gaps_s"> - <XRoom v-for="membership in memberships" :key="membership.id" :room="membership.room"/> + <XRoom v-for="membership in memberships" :key="membership.id" :room="membership.room!"/> </div> <div v-if="!fetching && memberships.length == 0" class="_fullinfo"> <div>{{ i18n.ts._chat.noRooms }}</div> @@ -16,19 +16,11 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, onMounted, ref } from 'vue'; +import { onMounted, ref } from 'vue'; import * as Misskey from 'misskey-js'; import XRoom from './XRoom.vue'; -import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; import { misskeyApi } from '@/utility/misskey-api.js'; -import { ensureSignin } from '@/i.js'; -import { useRouter } from '@/router.js'; -import * as os from '@/os.js'; - -const $i = ensureSignin(); - -const router = useRouter(); const fetching = ref(true); const memberships = ref<Misskey.entities.ChatRoomMembership[]>([]); @@ -36,8 +28,7 @@ const memberships = ref<Misskey.entities.ChatRoomMembership[]>([]); async function fetchRooms() { fetching.value = true; - const res = await misskeyApi('chat/rooms/joining', { - }); + const res = await misskeyApi('chat/rooms/joining'); memberships.value = res; diff --git a/packages/frontend/src/pages/chat/home.ownedRooms.vue b/packages/frontend/src/pages/chat/home.ownedRooms.vue index b0449fb373..ce7da15563 100644 --- a/packages/frontend/src/pages/chat/home.ownedRooms.vue +++ b/packages/frontend/src/pages/chat/home.ownedRooms.vue @@ -16,19 +16,11 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, onMounted, ref } from 'vue'; +import { onMounted, ref } from 'vue'; import * as Misskey from 'misskey-js'; import XRoom from './XRoom.vue'; -import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; import { misskeyApi } from '@/utility/misskey-api.js'; -import { ensureSignin } from '@/i.js'; -import { useRouter } from '@/router.js'; -import * as os from '@/os.js'; - -const $i = ensureSignin(); - -const router = useRouter(); const fetching = ref(true); const rooms = ref<Misskey.entities.ChatRoom[]>([]); diff --git a/packages/frontend/src/pages/chat/home.vue b/packages/frontend/src/pages/chat/home.vue index 9bb7235a64..e29ab28f2d 100644 --- a/packages/frontend/src/pages/chat/home.vue +++ b/packages/frontend/src/pages/chat/home.vue @@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, onMounted, ref } from 'vue'; +import { computed, ref } from 'vue'; import XHome from './home.home.vue'; import XInvitations from './home.invitations.vue'; import XJoiningRooms from './home.joiningRooms.vue'; diff --git a/packages/frontend/src/pages/chat/message.vue b/packages/frontend/src/pages/chat/message.vue index 975d1a2be9..3ac90a93fd 100644 --- a/packages/frontend/src/pages/chat/message.vue +++ b/packages/frontend/src/pages/chat/message.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader> <MkSpacer :contentMax="700"> - <div v-if="initializing"> + <div v-if="initializing || message == null"> <MkLoading/> </div> <div v-else> @@ -17,23 +17,19 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, useTemplateRef, computed, watch, onMounted, nextTick, onBeforeUnmount, onDeactivated, onActivated } from 'vue'; +import { ref, onMounted } from 'vue'; import * as Misskey from 'misskey-js'; import XMessage from './XMessage.vue'; -import * as os from '@/os.js'; -import { useStream } from '@/stream.js'; import { i18n } from '@/i18n.js'; -import { ensureSignin } from '@/i.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { definePage } from '@/page.js'; -import MkButton from '@/components/MkButton.vue'; const props = defineProps<{ messageId?: string; }>(); const initializing = ref(true); -const message = ref<Misskey.entities.ChatMessage>(); +const message = ref<Misskey.entities.ChatMessage | null>(); async function initialize() { initializing.value = true; diff --git a/packages/frontend/src/pages/chat/room.form.vue b/packages/frontend/src/pages/chat/room.form.vue index 27ddbeb565..9389b16ce7 100644 --- a/packages/frontend/src/pages/chat/room.form.vue +++ b/packages/frontend/src/pages/chat/room.form.vue @@ -34,14 +34,12 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, watch, ref, shallowRef, computed, nextTick, readonly } from 'vue'; +import { onMounted, watch, ref, shallowRef, computed, nextTick, readonly, onBeforeUnmount } from 'vue'; import * as Misskey from 'misskey-js'; //import insertTextAtCursor from 'insert-text-at-cursor'; -import { throttle } from 'throttle-debounce'; import { formatTimeString } from '@/utility/format-time-string.js'; import { selectFile } from '@/utility/select-file.js'; import * as os from '@/os.js'; -import { useStream } from '@/stream.js'; import { i18n } from '@/i18n.js'; import { uploadFile } from '@/utility/upload.js'; import { miLocalStorage } from '@/local-storage.js'; @@ -62,6 +60,7 @@ const text = ref<string>(''); const file = ref<Misskey.entities.DriveFile | null>(null); const sending = ref(false); const textareaReadOnly = ref(false); +let autocompleteInstance: Autocomplete | null = null; const canSend = computed(() => (text.value != null && text.value !== '') || file.value != null); @@ -171,7 +170,9 @@ function chooseFile(ev: MouseEvent) { } function onChangeFile() { - if (fileEl.value.files![0]) upload(fileEl.value.files[0]); + if (fileEl.value == null || fileEl.value.files == null) return; + + if (fileEl.value.files[0]) upload(fileEl.value.files[0]); } function upload(fileToUpload: File, name?: string) { @@ -270,8 +271,9 @@ async function insertEmoji(ev: MouseEvent) { } onMounted(() => { - // TODO: detach when unmount - new Autocomplete(textareaEl.value, text); + if (textareaEl.value != null) { + autocompleteInstance = new Autocomplete(textareaEl.value, text); + } // 書きかけの投稿を復元 const draft = JSON.parse(miLocalStorage.getItem('chatMessageDrafts') || '{}')[getDraftKey()]; @@ -280,6 +282,13 @@ onMounted(() => { file.value = draft.data.file; } }); + +onBeforeUnmount(() => { + if (autocompleteInstance) { + autocompleteInstance.detach(); + autocompleteInstance = null; + } +}); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/chat/room.info.vue b/packages/frontend/src/pages/chat/room.info.vue index 8439e5f772..2f091388a0 100644 --- a/packages/frontend/src/pages/chat/room.info.vue +++ b/packages/frontend/src/pages/chat/room.info.vue @@ -26,11 +26,10 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, onMounted, ref, watch } from 'vue'; +import { computed, ref, watch } from 'vue'; import * as Misskey from 'misskey-js'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; -import { misskeyApi } from '@/utility/misskey-api.js'; import * as os from '@/os.js'; import { ensureSignin } from '@/i.js'; import MkInput from '@/components/MkInput.vue'; @@ -73,7 +72,7 @@ async function del() { router.push('/chat'); } -const isMuted = ref(props.room.isMuted); +const isMuted = ref(props.room.isMuted ?? false); watch(isMuted, async () => { await os.apiWithDialog('chat/rooms/mute', { diff --git a/packages/frontend/src/pages/chat/room.members.vue b/packages/frontend/src/pages/chat/room.members.vue index bff038570f..5a574068cb 100644 --- a/packages/frontend/src/pages/chat/room.members.vue +++ b/packages/frontend/src/pages/chat/room.members.vue @@ -14,8 +14,8 @@ SPDX-License-Identifier: AGPL-3.0-only <hr v-if="memberships.length > 0"> <div v-for="membership in memberships" :key="membership.id" :class="$style.membership"> - <MkA :class="$style.membershipBody" :to="`${userPage(membership.user)}`"> - <MkUserCardMini :user="membership.user"/> + <MkA :class="$style.membershipBody" :to="`${userPage(membership.user!)}`"> + <MkUserCardMini :user="membership.user!"/> </MkA> </div> @@ -39,7 +39,6 @@ import * as Misskey from 'misskey-js'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; import { misskeyApi } from '@/utility/misskey-api.js'; -import * as os from '@/os.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; import { userPage } from '@/filters/user.js'; import { ensureSignin } from '@/i.js'; diff --git a/packages/frontend/src/pages/chat/room.search.vue b/packages/frontend/src/pages/chat/room.search.vue index e382834578..20b6e22a46 100644 --- a/packages/frontend/src/pages/chat/room.search.vue +++ b/packages/frontend/src/pages/chat/room.search.vue @@ -33,14 +33,13 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, onMounted, ref } from 'vue'; +import { ref } from 'vue'; import * as Misskey from 'misskey-js'; import XMessage from './XMessage.vue'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; import { infoImageUrl } from '@/instance.js'; import { misskeyApi } from '@/utility/misskey-api.js'; -import * as os from '@/os.js'; import MkInput from '@/components/MkInput.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; diff --git a/packages/frontend/src/pages/chat/room.vue b/packages/frontend/src/pages/chat/room.vue index ce823968f7..9942dbeee9 100644 --- a/packages/frontend/src/pages/chat/room.vue +++ b/packages/frontend/src/pages/chat/room.vue @@ -38,7 +38,14 @@ SPDX-License-Identifier: AGPL-3.0-only :moveClass="prefer.s.animation ? $style.transition_x_move : ''" tag="div" class="_gaps" > - <XMessage v-for="message in messages.toReversed()" :key="message.id" :message="message"/> + <template v-for="item in timeline.toReversed()" :key="item.id"> + <XMessage v-if="item.type === 'item'" :message="item.data"/> + <div v-else-if="item.type === 'date'" :class="$style.dateDivider"> + <span><i class="ti ti-chevron-up"></i> {{ item.nextText }}</span> + <span style="height: 1em; width: 1px; background: var(--MI_THEME-divider);"></span> + <span>{{ item.prevText }} <i class="ti ti-chevron-down"></i></span> + </div> + </template> </TransitionGroup> </div> @@ -79,15 +86,16 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, useTemplateRef, computed, watch, onMounted, nextTick, onBeforeUnmount, onDeactivated, onActivated } from 'vue'; +import { ref, useTemplateRef, computed, onMounted, onBeforeUnmount, onDeactivated, onActivated } from 'vue'; import * as Misskey from 'misskey-js'; -import { getScrollContainer, isTailVisible } from '@@/js/scroll.js'; +import { getScrollContainer } from '@@/js/scroll.js'; import XMessage from './XMessage.vue'; import XForm from './room.form.vue'; import XSearch from './room.search.vue'; import XMembers from './room.members.vue'; import XInfo from './room.info.vue'; import type { MenuItem } from '@/types/menu.js'; +import type { PageHeaderItem } from '@/types/page-header.js'; import * as os from '@/os.js'; import { useStream } from '@/stream.js'; import * as sound from '@/utility/sound.js'; @@ -100,6 +108,7 @@ import MkButton from '@/components/MkButton.vue'; import { useRouter } from '@/router.js'; import { useMutationObserver } from '@/use/use-mutation-observer.js'; import MkInfo from '@/components/MkInfo.vue'; +import { makeDateSeparatedTimelineComputedRef } from '@/utility/timeline-date-separate.js'; const $i = ensureSignin(); const router = useRouter(); @@ -109,15 +118,23 @@ const props = defineProps<{ roomId?: string; }>(); +export type NormalizedChatMessage = Omit<Misskey.entities.ChatMessageLite, 'fromUser' | 'reactions'> & { + fromUser: Misskey.entities.UserLite; + reactions: (Misskey.entities.ChatMessageLite['reactions'][number] & { + user: Misskey.entities.UserLite; + })[]; +}; + const initializing = ref(true); const moreFetching = ref(false); -const messages = ref<Misskey.entities.ChatMessage[]>([]); +const messages = ref<NormalizedChatMessage[]>([]); const canFetchMore = ref(false); const user = ref<Misskey.entities.UserDetailed | null>(null); const room = ref<Misskey.entities.ChatRoom | null>(null); -const connection = ref<Misskey.ChannelConnection<Misskey.Channels['chatUser'] | Misskey.Channels['chatRoom']> | null>(null); +const connection = ref<Misskey.IChannelConnection<Misskey.Channels['chatUser']> | Misskey.IChannelConnection<Misskey.Channels['chatRoom']> | null>(null); const showIndicator = ref(false); const timelineEl = useTemplateRef('timelineEl'); +const timeline = makeDateSeparatedTimelineComputedRef(messages); const SCROLL_HEAD_THRESHOLD = 200; @@ -138,18 +155,14 @@ useMutationObserver(timelineEl, { } }); -function normalizeMessage(message: Misskey.entities.ChatMessageLite | Misskey.entities.ChatMessage) { - const reactions = [...message.reactions]; - for (const record of reactions) { - if (room.value == null && record.user == null) { // 1on1の時はuserは省略される - record.user = message.fromUserId === $i.id ? user.value : $i; - } - } - +function normalizeMessage(message: Misskey.entities.ChatMessageLite | Misskey.entities.ChatMessage): NormalizedChatMessage { return { ...message, - fromUser: message.fromUser ?? (message.fromUserId === $i.id ? $i : user), - reactions, + fromUser: message.fromUser ?? (message.fromUserId === $i.id ? $i : user.value!), + reactions: message.reactions.map(record => ({ + ...record, + user: record.user ?? (message.fromUserId === $i.id ? user.value! : $i), + })), }; } @@ -184,8 +197,8 @@ async function initialize() { misskeyApi('chat/messages/room-timeline', { roomId: props.roomId, limit: LIMIT }), ]); - room.value = r; - messages.value = m.map(x => normalizeMessage(x)); + room.value = r as Misskey.entities.ChatRoomsShowResponse; + messages.value = (m as Misskey.entities.ChatMessagesRoomTimelineResponse).map(x => normalizeMessage(x)); if (messages.value.length === LIMIT) { canFetchMore.value = true; @@ -221,11 +234,11 @@ async function fetchMore() { moreFetching.value = true; const newMessages = props.userId ? await misskeyApi('chat/messages/user-timeline', { - userId: user.value.id, + userId: user.value!.id, limit: LIMIT, untilId: messages.value[messages.value.length - 1].id, }) : await misskeyApi('chat/messages/room-timeline', { - roomId: room.value.id, + roomId: room.value!.id, limit: LIMIT, untilId: messages.value[messages.value.length - 1].id, }); @@ -236,7 +249,7 @@ async function fetchMore() { moreFetching.value = false; } -function onMessage(message: Misskey.entities.ChatMessage) { +function onMessage(message: Misskey.entities.ChatMessageLite) { sound.playMisskeySfx('chatMessage'); messages.value.unshift(normalizeMessage(message)); @@ -253,34 +266,34 @@ function onMessage(message: Misskey.entities.ChatMessage) { } } -function onDeleted(id) { +function onDeleted(id: string) { const index = messages.value.findIndex(m => m.id === id); if (index !== -1) { messages.value.splice(index, 1); } } -function onReact(ctx) { +function onReact(ctx: Parameters<Misskey.Channels['chatUser']['events']['react']>[0] | Parameters<Misskey.Channels['chatRoom']['events']['react']>[0]) { const message = messages.value.find(m => m.id === ctx.messageId); if (message) { if (room.value == null) { // 1on1の時はuserは省略される message.reactions.push({ reaction: ctx.reaction, - user: message.fromUserId === $i.id ? user : $i, + user: message.fromUserId === $i.id ? user.value! : $i, }); } else { message.reactions.push({ reaction: ctx.reaction, - user: ctx.user, + user: ctx.user!, }); } } } -function onUnreact(ctx) { +function onUnreact(ctx: Parameters<Misskey.Channels['chatUser']['events']['unreact']>[0] | Parameters<Misskey.Channels['chatRoom']['events']['unreact']>[0]) { const message = messages.value.find(m => m.id === ctx.messageId); if (message) { - const index = message.reactions.findIndex(r => r.reaction === ctx.reaction && r.user.id === ctx.user.id); + const index = message.reactions.findIndex(r => r.reaction === ctx.reaction && r.user.id === ctx.user!.id); if (index !== -1) { message.reactions.splice(index, 1); } @@ -310,14 +323,18 @@ onBeforeUnmount(() => { }); async function inviteUser() { + if (room.value == null) return; + const invitee = await os.selectUser({ includeSelf: false, localOnly: true }); os.apiWithDialog('chat/rooms/invitations/create', { - roomId: room.value?.id, + roomId: room.value.id, userId: invitee.id, }); } async function leaveRoom() { + if (room.value == null) return; + const { canceled } = await os.confirm({ type: 'warning', text: i18n.ts.areYouSure, @@ -325,7 +342,7 @@ async function leaveRoom() { if (canceled) return; misskeyApi('chat/rooms/leave', { - roomId: room.value?.id, + roomId: room.value.id, }); router.push('/chat'); } @@ -384,19 +401,36 @@ const headerTabs = computed(() => room.value ? [{ icon: 'ti ti-search', }]); -const headerActions = computed(() => [{ +const headerActions = computed<PageHeaderItem[]>(() => [{ icon: 'ti ti-dots', + text: '', handler: showMenu, }]); -definePage(computed(() => !initializing.value ? user.value ? { - userName: user, - title: user.value.name ?? user.value.username, - avatar: user, -} : { - title: room.value?.name, - icon: 'ti ti-users', -} : null)); +definePage(computed(() => { + if (!initializing.value) { + if (user.value) { + return { + userName: user.value, + title: user.value.name ?? user.value.username, + avatar: user.value, + }; + } else if (room.value) { + return { + title: room.value.name, + icon: 'ti ti-users', + }; + } else { + return { + title: i18n.ts.chat, + }; + } + } else { + return { + title: i18n.ts.chat, + }; + } +})); </script> <style lang="scss" module> @@ -464,4 +498,18 @@ definePage(computed(() => !initializing.value ? user.value ? { transition: opacity 0.5s; opacity: 0; } + +.dateDivider { + display: flex; + font-size: 85%; + align-items: center; + justify-content: center; + gap: 0.5em; + opacity: 0.75; + border: solid 0.5px var(--MI_THEME-divider); + border-radius: 999px; + width: fit-content; + padding: 0.5em 1em; + margin: 0 auto; +} </style> diff --git a/packages/frontend/src/use/use-mutation-observer.ts b/packages/frontend/src/use/use-mutation-observer.ts index b35dbcd7a8..7b774022dc 100644 --- a/packages/frontend/src/use/use-mutation-observer.ts +++ b/packages/frontend/src/use/use-mutation-observer.ts @@ -4,9 +4,9 @@ */ import { onUnmounted, watch } from 'vue'; -import type { Ref, ShallowRef } from 'vue'; +import type { Ref } from 'vue'; -export function useMutationObserver(targetNodeRef: Ref<HTMLElement | undefined>, options: MutationObserverInit, callback: MutationCallback): void { +export function useMutationObserver(targetNodeRef: Ref<HTMLElement | null | undefined>, options: MutationObserverInit, callback: MutationCallback): void { const observer = new MutationObserver(callback); watch(targetNodeRef, (targetNode) => { diff --git a/packages/frontend/src/utility/timeline-date-separate.ts b/packages/frontend/src/utility/timeline-date-separate.ts new file mode 100644 index 0000000000..e1bc9790b9 --- /dev/null +++ b/packages/frontend/src/utility/timeline-date-separate.ts @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { computed } from 'vue'; +import type { Ref } from 'vue'; + +export function getDateText(dateInstance: Date) { + const date = dateInstance.getDate(); + const month = dateInstance.getMonth() + 1; + return `${month.toString()}/${date.toString()}`; +} + +export type DateSeparetedTimelineItem<T> = { + id: string; + type: 'item'; + data: T; +} | { + id: string; + type: 'date'; + prev: Date; + prevText: string; + next: Date; + nextText: string; +}; + +export function makeDateSeparatedTimelineComputedRef<T extends { id: string; createdAt: string; }>(items: Ref<T[]>) { + return computed<DateSeparetedTimelineItem<T>[]>(() => { + const tl: DateSeparetedTimelineItem<T>[] = []; + for (let i = 0; i < items.value.length; i++) { + const item = items.value[i]; + + const date = new Date(item.createdAt); + const nextDate = items.value[i + 1] ? new Date(items.value[i + 1].createdAt) : null; + + tl.push({ + id: item.id, + type: 'item', + data: item, + }); + + if ( + i !== items.value.length - 1 && + nextDate != null && ( + date.getFullYear() !== nextDate.getFullYear() || + date.getMonth() !== nextDate.getMonth() || + date.getDate() !== nextDate.getDate() + ) + ) { + tl.push({ + id: `date-${item.id}`, + type: 'date', + prev: date, + prevText: getDateText(date), + next: nextDate, + nextText: getDateText(nextDate), + }); + } + } + return tl; + }); +} |