diff options
| author | syuilo <4439005+syuilo@users.noreply.github.com> | 2025-03-24 21:32:46 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-03-24 21:32:46 +0900 |
| commit | f1f24e39d2df3135493e2c2087230b428e2d02b7 (patch) | |
| tree | a5ae0e9d2cf810649b2f4e08ef4d00ce7ea91dc9 /packages/frontend/src | |
| parent | fix(frontend): fix broken styles (diff) | |
| download | sharkey-f1f24e39d2df3135493e2c2087230b428e2d02b7.tar.gz sharkey-f1f24e39d2df3135493e2c2087230b428e2d02b7.tar.bz2 sharkey-f1f24e39d2df3135493e2c2087230b428e2d02b7.zip | |
Feat: Chat (#15686)
* wip
* wip
* wip
* wip
* wip
* wip
* Update types.ts
* Create 1742203321812-chat.js
* wip
* wip
* Update room.vue
* Update home.vue
* Update home.vue
* Update ja-JP.yml
* Update index.d.ts
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* Update CHANGELOG.md
* wip
* Update home.vue
* clean up
* Update misskey-js.api.md
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* lint fixes
* lint
* Update UserEntityService.ts
* search
* wip
* 🎨
* wip
* Update home.ownedRooms.vue
* wip
* Update CHANGELOG.md
* Update style.scss
* wip
* improve performance
* improve performance
* Update timeline.test.ts
Diffstat (limited to 'packages/frontend/src')
38 files changed, 2133 insertions, 130 deletions
diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts index 134490e317..19371dff0e 100644 --- a/packages/frontend/src/boot/main-boot.ts +++ b/packages/frontend/src/boot/main-boot.ts @@ -502,31 +502,16 @@ export async function mainBoot() { }); }); - main.on('unreadMention', () => { - updateCurrentAccountPartial({ hasUnreadMentions: true }); - }); - - main.on('readAllUnreadMentions', () => { - updateCurrentAccountPartial({ hasUnreadMentions: false }); - }); - - main.on('unreadSpecifiedNote', () => { - updateCurrentAccountPartial({ hasUnreadSpecifiedNotes: true }); - }); - - main.on('readAllUnreadSpecifiedNotes', () => { - updateCurrentAccountPartial({ hasUnreadSpecifiedNotes: false }); - }); - - main.on('readAllAntennas', () => { - updateCurrentAccountPartial({ hasUnreadAntenna: false }); - }); - main.on('unreadAntenna', () => { updateCurrentAccountPartial({ hasUnreadAntenna: true }); sound.playMisskeySfx('antenna'); }); + main.on('newChatMessage', () => { + updateCurrentAccountPartial({ hasUnreadChatMessages: true }); + sound.playMisskeySfx('chat'); + }); + main.on('readAllAnnouncements', () => { updateCurrentAccountPartial({ hasUnreadAnnouncement: false }); }); diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue index 5e89dfba12..891af7f696 100644 --- a/packages/frontend/src/components/MkButton.vue +++ b/packages/frontend/src/components/MkButton.vue @@ -7,11 +7,11 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-if="!link" ref="el" class="_button" - :class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly }]" + :class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly, [$style.wait]: wait }]" :type="type" :name="name" :value="value" - :disabled="disabled" + :disabled="disabled || wait" @click="emit('click', $event)" @mousedown="onMousedown" > @@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only </button> <MkA v-else class="_button" - :class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly }]" + :class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly, [$style.wait]: wait }]" :to="to ?? '#'" :behavior="linkBehavior" @mousedown="onMousedown" @@ -256,6 +256,10 @@ function onMousedown(evt: MouseEvent): void { opacity: 0.5; } + &.wait { + cursor: wait !important; + } + &:focus-visible { outline-offset: 2px; } diff --git a/packages/frontend/src/components/MkDateSeparatedList.vue b/packages/frontend/src/components/MkDateSeparatedList.vue index b5842876ac..ec6fcdc311 100644 --- a/packages/frontend/src/components/MkDateSeparatedList.vue +++ b/packages/frontend/src/components/MkDateSeparatedList.vue @@ -168,21 +168,17 @@ export default defineComponent({ container-type: inline-size; &:global { - > .list-move { - transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1); - } - - &.deny-move-transition > .list-move { - transition: none !important; - } + > .list-move { + transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1); + } - > .list-enter-active { - transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1); - } + > .list-enter-active { + transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1); + } - > *:empty { - display: none; - } + > *:empty { + display: none; + } } &:not(.date-separated-list-nogap) > *:not(:last-child) { diff --git a/packages/frontend/src/components/MkFukidashi.vue b/packages/frontend/src/components/MkFukidashi.vue index 8b1c56fca4..e9544afa35 100644 --- a/packages/frontend/src/components/MkFukidashi.vue +++ b/packages/frontend/src/components/MkFukidashi.vue @@ -10,6 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only tail === 'left' ? $style.left : $style.right, negativeMargin === true && $style.negativeMargin, shadow === true && $style.shadow, + accented === true && $style.accented ]" > <div :class="$style.bg"> @@ -30,10 +31,12 @@ withDefaults(defineProps<{ tail?: 'left' | 'right' | 'none'; negativeMargin?: boolean; shadow?: boolean; + accented?: boolean; }>(), { tail: 'right', negativeMargin: false, shadow: false, + accented: false, }); </script> @@ -47,6 +50,10 @@ withDefaults(defineProps<{ min-height: calc(var(--fukidashi-radius) * 2); padding-top: calc(var(--fukidashi-radius) * .13); + &.accented { + --fukidashi-bg: var(--MI_THEME-accent); + } + &.shadow { filter: drop-shadow(0 4px 32px var(--MI_THEME-shadow)); } @@ -77,7 +84,7 @@ withDefaults(defineProps<{ .content { position: relative; - padding: 8px 12px; + padding: 10px 14px; } .tail { diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue index ae15776041..4a1100c324 100644 --- a/packages/frontend/src/components/MkMediaList.vue +++ b/packages/frontend/src/components/MkMediaList.vue @@ -227,7 +227,6 @@ defineExpose({ .container { position: relative; width: 100%; - margin-top: 4px; } .medias { diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue index a84bd9b256..f2f36308ca 100644 --- a/packages/frontend/src/components/MkMenu.vue +++ b/packages/frontend/src/components/MkMenu.vue @@ -11,6 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only [$style.center]: align === 'center', [$style.big]: big, [$style.asDrawer]: asDrawer, + [$style.widthSpecified]: width != null, }" @focusin.passive.stop="() => {}" > @@ -29,15 +30,19 @@ SPDX-License-Identifier: AGPL-3.0-only > <template v-for="item in (items2 ?? [])"> <div v-if="item.type === 'divider'" role="separator" tabindex="-1" :class="$style.divider"></div> + <span v-else-if="item.type === 'label'" role="menuitem" tabindex="-1" :class="[$style.label, $style.item]"> <span style="opacity: 0.7;">{{ item.text }}</span> </span> + <span v-else-if="item.type === 'pending'" role="menuitem" tabindex="0" :class="[$style.pending, $style.item]"> <span><MkEllipsis/></span> </span> + <div v-else-if="item.type === 'component'" role="menuitem" tabindex="-1" :class="[$style.componentItem]"> <component :is="item.component" v-bind="item.props"/> </div> + <MkA v-else-if="item.type === 'link'" role="menuitem" @@ -51,10 +56,14 @@ SPDX-License-Identifier: AGPL-3.0-only <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"/> <div :class="$style.item_content"> - <span :class="$style.item_content_text">{{ item.text }}</span> + <div :class="$style.item_content_text"> + <div :class="$style.item_content_text_title">{{ item.text }}</div> + <div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div> + </div> <span v-if="item.indicate" :class="$style.indicator" class="_blink"><i class="_indicatorCircle"></i></span> </div> </MkA> + <a v-else-if="item.type === 'a'" role="menuitem" @@ -70,10 +79,14 @@ SPDX-License-Identifier: AGPL-3.0-only > <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i> <div :class="$style.item_content"> - <span :class="$style.item_content_text">{{ item.text }}</span> + <div :class="$style.item_content_text"> + <div :class="$style.item_content_text_title">{{ item.text }}</div> + <div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div> + </div> <span v-if="item.indicate" :class="$style.indicator" class="_blink"><i class="_indicatorCircle"></i></span> </div> </a> + <button v-else-if="item.type === 'user'" role="menuitem" @@ -88,6 +101,7 @@ SPDX-License-Identifier: AGPL-3.0-only <span :class="$style.indicator" class="_blink"><i class="_indicatorCircle"></i></span> </div> </button> + <button v-else-if="item.type === 'switch'" role="menuitemcheckbox" @@ -101,10 +115,14 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i> <MkSwitchButton v-else :class="$style.switchButton" :checked="item.ref" :disabled="item.disabled" @toggle="switchItem(item)"/> <div :class="$style.item_content"> - <span :class="[$style.item_content_text, { [$style.switchText]: !item.icon }]">{{ item.text }}</span> + <div :class="[$style.item_content_text, { [$style.switchText]: !item.icon }]"> + <div :class="$style.item_content_text_title">{{ item.text }}</div> + <div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div> + </div> <MkSwitchButton v-if="item.icon" :class="[$style.switchButton, $style.caret]" :checked="item.ref" :disabled="item.disabled" @toggle="switchItem(item)"/> </div> </button> + <button v-else-if="item.type === 'radio'" role="menuitem" @@ -117,10 +135,14 @@ SPDX-License-Identifier: AGPL-3.0-only > <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]" style="pointer-events: none;"></i> <div :class="$style.item_content"> - <span :class="$style.item_content_text" style="pointer-events: none;">{{ item.text }}</span> + <div :class="$style.item_content_text" style="pointer-events: none;"> + <div :class="$style.item_content_text_title">{{ item.text }}</div> + <div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div> + </div> <span :class="$style.caret" style="pointer-events: none;"><i class="ti ti-chevron-right ti-fw"></i></span> </div> </button> + <button v-else-if="item.type === 'radioOption'" role="menuitemradio" @@ -134,9 +156,13 @@ SPDX-License-Identifier: AGPL-3.0-only <span :class="[$style.radioIcon, { [$style.radioChecked]: unref(item.active) }]"></span> </div> <div :class="$style.item_content"> - <span :class="$style.item_content_text">{{ item.text }}</span> + <div :class="$style.item_content_text"> + <div :class="$style.item_content_text_title">{{ item.text }}</div> + <div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div> + </div> </div> </button> + <button v-else-if="item.type === 'parent'" role="menuitem" @@ -148,12 +174,17 @@ SPDX-License-Identifier: AGPL-3.0-only > <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]" style="pointer-events: none;"></i> <div :class="$style.item_content"> - <span :class="$style.item_content_text" style="pointer-events: none;">{{ item.text }}</span> + <div :class="$style.item_content_text" style="pointer-events: none;"> + <div :class="$style.item_content_text_title">{{ item.text }}</div> + <div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div> + </div> <span :class="$style.caret" style="pointer-events: none;"><i class="ti ti-chevron-right ti-fw"></i></span> </div> </button> + <button - v-else role="menuitem" + v-else + role="menuitem" tabindex="0" :class="['_button', $style.item, { [$style.danger]: item.danger, [$style.active]: unref(item.active) }]" @click.prevent="unref(item.active) ? close(false) : clicked(item.action, $event)" @@ -163,11 +194,15 @@ SPDX-License-Identifier: AGPL-3.0-only <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"/> <div :class="$style.item_content"> - <span :class="$style.item_content_text">{{ item.text }}</span> + <div :class="$style.item_content_text"> + <div :class="$style.item_content_text_title">{{ item.text }}</div> + <div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div> + </div> <span v-if="item.indicate" :class="$style.indicator" class="_blink"><i class="_indicatorCircle"></i></span> </div> </button> </template> + <span v-if="items2 == null || items2.length === 0" tabindex="-1" :class="[$style.none, $style.item]"> <span>{{ i18n.ts.none }}</span> </span> @@ -438,6 +473,12 @@ onBeforeUnmount(() => { } } + &:not(.widthSpecified) { + > .menu { + max-width: 400px; + } + } + &.big:not(.asDrawer) { > .menu { min-width: 230px; @@ -607,10 +648,19 @@ onBeforeUnmount(() => { .item_content_text { max-width: calc(100vw - 4rem); +} + +.item_content_text_title { text-overflow: ellipsis; overflow: hidden; } +.item_content_text_caption { + text-wrap: auto; + font-size: 85%; + opacity: 0.7; +} + .switchButton { margin-left: -2px; --height: 1.35em; diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue index ab8bda403b..a729619180 100644 --- a/packages/frontend/src/components/MkPagination.vue +++ b/packages/frontend/src/components/MkPagination.vue @@ -24,16 +24,16 @@ SPDX-License-Identifier: AGPL-3.0-only </slot> </div> - <div v-else ref="rootEl"> - <div v-show="pagination.reversed && more" key="_more_" class="_margin"> - <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMoreAhead : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMoreAhead"> + <div v-else ref="rootEl" class="_gaps"> + <div v-show="pagination.reversed && more" key="_more_"> + <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMoreAhead : null" :class="$style.more" :wait="moreFetching" primary rounded @click="fetchMoreAhead"> {{ i18n.ts.loadMore }} </MkButton> <MkLoading v-else class="loading"/> </div> <slot :items="Array.from(items.values())" :fetching="fetching || moreFetching"></slot> - <div v-show="!pagination.reversed && more" key="_more_" class="_margin"> - <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMore : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMore"> + <div v-show="!pagination.reversed && more" key="_more_"> + <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMore : null" :class="$style.more" :wait="moreFetching" primary rounded @click="fetchMore"> {{ i18n.ts.loadMore }} </MkButton> <MkLoading v-else class="loading"/> @@ -46,7 +46,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, isRef, nextTick, onActivated, onBeforeMount, onBeforeUnmount, onDeactivated, ref, useTemplateRef, watch } from 'vue'; import * as Misskey from 'misskey-js'; import { useDocumentVisibility } from '@@/js/use-document-visibility.js'; -import { onScrollTop, isTopVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scroll, isBottomVisible } from '@@/js/scroll.js'; +import { onScrollTop, isHeadVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scroll, isTailVisible } from '@@/js/scroll.js'; import type { ComputedRef } from 'vue'; import type { MisskeyEntity } from '@/types/date-separated-list.js'; import { misskeyApi } from '@/utility/misskey-api.js'; @@ -74,8 +74,6 @@ export type Paging<E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints> reversed?: boolean; offsetMode?: boolean; - - pageEl?: HTMLElement; }; type MisskeyEntityMap = Map<string, MisskeyEntity>; @@ -141,8 +139,7 @@ const { enableInfiniteScroll, } = prefer.r; -const contentEl = computed(() => props.pagination.pageEl ?? rootEl.value); -const scrollableElement = computed(() => contentEl.value ? getScrollContainer(contentEl.value) : window.document.body); +const scrollableElement = computed(() => rootEl.value ? getScrollContainer(rootEl.value) : window.document.body); const visibility = useDocumentVisibility(); @@ -173,13 +170,13 @@ watch(rootEl, () => { }); }); -watch([backed, contentEl], () => { +watch([backed, rootEl], () => { if (!backed.value) { - if (!contentEl.value) return; + if (!rootEl.value) return; scrollRemove.value = props.pagination.reversed - ? onScrollBottom(contentEl.value, executeQueue, TOLERANCE) - : onScrollTop(contentEl.value, (topVisible) => { if (topVisible) executeQueue(); }, TOLERANCE); + ? onScrollBottom(rootEl.value, executeQueue, TOLERANCE) + : onScrollTop(rootEl.value, (topVisible) => { if (topVisible) executeQueue(); }, TOLERANCE); } else { if (scrollRemove.value) scrollRemove.value(); scrollRemove.value = null; @@ -349,7 +346,7 @@ const appearFetchMoreAhead = async (): Promise<void> => { fetchMoreAppearTimeout(); }; -const isTop = (): boolean => isBackTop.value || (props.pagination.reversed ? isBottomVisible : isTopVisible)(contentEl.value!, TOLERANCE); +const isHead = (): boolean => isBackTop.value || (props.pagination.reversed ? isTailVisible : isHeadVisible)(rootEl.value!, TOLERANCE); watch(visibility, () => { if (visibility.value === 'hidden') { @@ -364,7 +361,7 @@ watch(visibility, () => { timerForSetPause = null; } else { isPausingUpdate = false; - if (isTop()) { + if (isHead()) { executeQueue(); } } @@ -376,16 +373,18 @@ watch(visibility, () => { * ストリーミングから降ってきたアイテムはこれで追加する * @param item アイテム */ -const prepend = (item: MisskeyEntity): void => { +function prepend(item: MisskeyEntity): void { if (items.value.size === 0) { items.value.set(item.id, item); fetching.value = false; return; } - if (isTop() && !isPausingUpdate) unshiftItems([item]); + console.log(isHead(), isPausingUpdate); + + if (isHead() && !isPausingUpdate) unshiftItems([item]); else prependQueue(item); -}; +} /** * 新着アイテムをitemsの先頭に追加し、displayLimitを適用する @@ -447,7 +446,7 @@ onDeactivated(() => { }); function toBottom() { - scrollToBottom(contentEl.value!); + scrollToBottom(rootEl.value!); } onBeforeMount(() => { diff --git a/packages/frontend/src/components/MkPolkadots.vue b/packages/frontend/src/components/MkPolkadots.vue new file mode 100644 index 0000000000..285c4d0b79 --- /dev/null +++ b/packages/frontend/src/components/MkPolkadots.vue @@ -0,0 +1,40 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="[$style.root, accented ? $style.accented : null]"></div> +</template> + +<script lang="ts" setup> +const props = withDefaults(defineProps<{ + accented?: boolean; +}>(), { + accented: false, +}); +</script> + +<style lang="scss" module> +.root { + --c: var(--MI_THEME-divider); + + &.accented { + --c: var(--MI_THEME-accent); + opacity: 0.5; + } + + --dot-size: 2px; + --gap-size: 40px; + --offset: calc(var(--gap-size) / 2); + + height: 200px; + margin-bottom: -200px; + + background-image: linear-gradient(transparent 60%, transparent 100%), radial-gradient(var(--c) var(--dot-size), transparent var(--dot-size)), radial-gradient(var(--c) var(--dot-size), transparent var(--dot-size)); + background-position: 0 0, 0 0, var(--offset) var(--offset); + background-size: 100% 100%, var(--gap-size) var(--gap-size), var(--gap-size) var(--gap-size); + mask-image: linear-gradient(to bottom, black 0%, transparent 100%); + pointer-events: none; +} +</style> diff --git a/packages/frontend/src/components/MkUrlPreview.vue b/packages/frontend/src/components/MkUrlPreview.vue index f20aee0ce3..20dab6f028 100644 --- a/packages/frontend/src/components/MkUrlPreview.vue +++ b/packages/frontend/src/components/MkUrlPreview.vue @@ -246,6 +246,7 @@ onUnmounted(() => { box-shadow: 0 0 0 1px var(--MI_THEME-divider); border-radius: 8px; overflow: clip; + text-align: left; &:hover { text-decoration: none; diff --git a/packages/frontend/src/local-storage.ts b/packages/frontend/src/local-storage.ts index 099339fbee..f6d6bbf0fb 100644 --- a/packages/frontend/src/local-storage.ts +++ b/packages/frontend/src/local-storage.ts @@ -28,7 +28,7 @@ export type Keys = ( 'theme' | 'themeId' | 'customCss' | - 'message_drafts' | + 'chatMessageDrafts' | 'scratchpad' | 'debug' | 'preferences' | diff --git a/packages/frontend/src/navbar.ts b/packages/frontend/src/navbar.ts index d478ece641..894df83721 100644 --- a/packages/frontend/src/navbar.ts +++ b/packages/frontend/src/navbar.ts @@ -4,6 +4,7 @@ */ import { computed, reactive } from 'vue'; +import { ui } from '@@/js/config.js'; import { clearCache } from './utility/clear-cache.js'; import { $i } from '@/i.js'; import { miLocalStorage } from '@/local-storage.js'; @@ -11,7 +12,6 @@ import { openInstanceMenu, openToolsMenu } from '@/ui/_common_/common.js'; import { lookup } from '@/utility/lookup.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { ui } from '@@/js/config.js'; import { unisonReload } from '@/utility/unison-reload.js'; export const navbarItemDef = reactive({ @@ -110,6 +110,12 @@ export const navbarItemDef = reactive({ icon: 'ti ti-device-tv', to: '/channels', }, + chat: { + title: i18n.ts.chat, + icon: 'ti ti-message', + to: '/chat', + indicated: computed(() => $i != null && $i.hasUnreadChatMessages), + }, achievements: { title: i18n.ts.achievements, icon: 'ti ti-medal', diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index 4e9f4edb70..d1e823215a 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -160,6 +160,26 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.canChat, 'canChat'])"> + <template #label>{{ i18n.ts._role._options.canChat }}</template> + <template #suffix> + <span v-if="role.policies.canChat.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span> + <span v-else>{{ role.policies.canChat.value ? i18n.ts.yes : i18n.ts.no }}</span> + <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canChat)"></i></span> + </template> + <div class="_gaps"> + <MkSwitch v-model="role.policies.canChat.useDefault" :readonly="readonly"> + <template #label>{{ i18n.ts._role.useBaseValue }}</template> + </MkSwitch> + <MkSwitch v-model="role.policies.canChat.value" :disabled="role.policies.canChat.useDefault" :readonly="readonly"> + <template #label>{{ i18n.ts.enable }}</template> + </MkSwitch> + <MkRange v-model="role.policies.canChat.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> + <template #label>{{ i18n.ts._role.priority }}</template> + </MkRange> + </div> + </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.mentionMax, 'mentionLimit'])"> <template #label>{{ i18n.ts._role._options.mentionMax }}</template> <template #suffix> diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue index 0428352350..df4efd1271 100644 --- a/packages/frontend/src/pages/admin/roles.vue +++ b/packages/frontend/src/pages/admin/roles.vue @@ -51,6 +51,14 @@ SPDX-License-Identifier: AGPL-3.0-only </MkSwitch> </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.canChat, 'canChat'])"> + <template #label>{{ i18n.ts._role._options.canChat }}</template> + <template #suffix>{{ policies.canChat ? i18n.ts.yes : i18n.ts.no }}</template> + <MkSwitch v-model="policies.canChat"> + <template #label>{{ i18n.ts.enable }}</template> + </MkSwitch> + </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.mentionMax, 'mentionLimit'])"> <template #label>{{ i18n.ts._role._options.mentionMax }}</template> <template #suffix>{{ policies.mentionLimit }}</template> diff --git a/packages/frontend/src/pages/chat/XMessage.vue b/packages/frontend/src/pages/chat/XMessage.vue new file mode 100644 index 0000000000..1e7f8e20ea --- /dev/null +++ b/packages/frontend/src/pages/chat/XMessage.vue @@ -0,0 +1,245 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +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"/> + <div :class="$style.body"> + <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"/> + <MkMediaList v-if="message.file" :mediaList="[message.file]" :class="$style.file"/> + </div> + <div v-else :class="$style.content"> + <p>{{ i18n.ts.deleted }}</p> + </div> + </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> + </div> + <TransitionGroup + :enterActiveClass="prefer.s.animation ? $style.transition_reaction_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_reaction_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_reaction_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_reaction_leaveTo : ''" + :moveClass="prefer.s.animation ? $style.transition_reaction_move : ''" + tag="div" :class="$style.reactions" + > + <div v-for="record in message.reactions" :key="record.reaction + record.user.id" :class="$style.reaction"> + <MkAvatar :user="record.user" :link="false" :class="$style.reactionAvatar"/> + <MkReactionIcon + :withTooltip="true" + :reaction="record.reaction.replace(/^:(\w+):$/, ':$1@.:')" + :noStyle="true" + :class="$style.reactionIcon" + /> + </div> + </TransitionGroup> + </div> +</div> +</template> + +<script lang="ts" setup> +import { computed, defineAsyncComponent } from 'vue'; +import * as mfm from 'mfm-js'; +import * as Misskey from 'misskey-js'; +import { url } from '@@/js/config.js'; +import type { MenuItem } from '@/types/menu.js'; +import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js'; +import MkUrlPreview from '@/components/MkUrlPreview.vue'; +import { ensureSignin } from '@/i.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { i18n } from '@/i18n.js'; +import MkFukidashi from '@/components/MkFukidashi.vue'; +import * as os from '@/os.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import MkMediaList from '@/components/MkMediaList.vue'; +import { reactionPicker } from '@/utility/reaction-picker.js'; +import * as sound from '@/utility/sound.js'; +import MkReactionIcon from '@/components/MkReactionIcon.vue'; +import { prefer } from '@/preferences.js'; + +const $i = ensureSignin(); + +const props = defineProps<{ + message: Misskey.entities.ChatMessageLite | Misskey.entities.ChatMessage; + isSearchResult?: boolean; +}>(); + +const isMe = computed(() => props.message.fromUserId === $i.id); +const urls = computed(() => props.message.text ? extractUrlFromMfm(mfm.parse(props.message.text)) : []); + +function react(ev: MouseEvent) { + reactionPicker.show(ev.currentTarget ?? ev.target, null, async (reaction) => { + sound.playMisskeySfx('reaction'); + + misskeyApi('chat/messages/react', { + messageId: props.message.id, + reaction: reaction, + }); + }); +} + +function showMenu(ev: MouseEvent) { + const menu: MenuItem[] = []; + + if (!isMe.value) { + menu.push({ + text: i18n.ts.reaction, + icon: 'ti ti-mood-plus', + action: (ev) => { + react(ev); + }, + }); + + menu.push({ + type: 'divider', + }); + } + + menu.push({ + text: i18n.ts.copyContent, + icon: 'ti ti-copy', + action: () => { + copyToClipboard(props.message.text); + }, + }); + + menu.push({ + type: 'divider', + }); + + if (isMe.value) { + menu.push({ + text: i18n.ts.delete, + icon: 'ti ti-trash', + danger: true, + action: () => { + misskeyApi('chat/messages/delete', { + messageId: props.message.id, + }); + }, + }); + } else { + 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, + initialComment: `${localUrl}\n-----\n`, + }, { + closed: () => dispose(), + }); + }, + }); + } + + os.popupMenu(menu, ev.currentTarget ?? ev.target); +} +</script> + +<style lang="scss" module> +.transition_reaction_move, +.transition_reaction_enterActive, +.transition_reaction_leaveActive { + transition: opacity 0.2s cubic-bezier(0,.5,.5,1), transform 0.2s cubic-bezier(0,.5,.5,1) !important; +} +.transition_reaction_enterFrom, +.transition_reaction_leaveTo { + opacity: 0; + transform: scale(0.7); +} +.transition_reaction_leaveActive { + position: absolute; +} + +.root { + position: relative; + display: flex; + + &.isMe { + flex-direction: row-reverse; + text-align: right; + + .content { + color: var(--MI_THEME-fgOnAccent); + } + + .footer { + flex-direction: row-reverse; + } + } +} + +.avatar { + position: sticky; + top: calc(16px + var(--MI-stickyTop, 0px)); + display: block; + width: 52px; + height: 52px; +} + +.body { + margin: 0 12px; +} + +.content { + overflow: clip; + overflow-wrap: break-word; + word-break: break-word; +} + +.file { +} + +.footer { + display: flex; + flex-direction: row; + gap: 0.5em; + margin-top: 4px; + font-size: 75%; +} + +.time { + opacity: 0.5; +} + +.reactions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 8px; + + &:empty { + display: none; + } +} + +.reaction { + display: flex; + align-items: center; + border: solid 1px var(--MI_THEME-divider); + border-radius: 999px; + padding: 8px; +} + +.reactionAvatar { + width: 24px; + height: 24px; + margin-right: 8px; +} + +.reactionIcon { + width: 24px; + height: 24px; +} +</style> diff --git a/packages/frontend/src/pages/chat/XRoom.vue b/packages/frontend/src/pages/chat/XRoom.vue new file mode 100644 index 0000000000..b063a0cdd1 --- /dev/null +++ b/packages/frontend/src/pages/chat/XRoom.vue @@ -0,0 +1,41 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkA :to="`/chat/room/${room.id}`" class="_panel _gaps_s" :class="$style.root"> + <div :class="$style.header"> + <div style="font-weight: bold;">{{ room.name }}</div> + <MkAvatar :user="room.owner" :link="false" :class="$style.headerAvatar"/> + </div> + <hr> + <div>{{ room.description }}</div> +</MkA> +</template> + +<script lang="ts" setup> +import * as Misskey from 'misskey-js'; + +const props = defineProps<{ + room: Misskey.entities.ChatRoom; +}>(); + +</script> + +<style lang="scss" module> +.root { + padding: 16px; +} + +.header { + display: flex; + align-items: center; +} + +.headerAvatar { + width: 30px; + height: 30px; + margin-left: auto; +} +</style> diff --git a/packages/frontend/src/pages/chat/home.home.vue b/packages/frontend/src/pages/chat/home.home.vue new file mode 100644 index 0000000000..1d0605136c --- /dev/null +++ b/packages/frontend/src/pages/chat/home.home.vue @@ -0,0 +1,252 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div class="_gaps"> + <MkButton primary gradate rounded :class="$style.start" @click="start"><i class="ti ti-plus"></i> {{ i18n.ts.startChat }}</MkButton> + + <MkAd :prefer="['horizontal', 'horizontal-big']"/> + + <MkInput + v-model="searchQuery" + :placeholder="i18n.ts._chat.searchMessages" + type="search" + > + <template #prefix><i class="ti ti-search"></i></template> + </MkInput> + + <MkButton v-if="searchQuery.length > 0" primary rounded @click="search">{{ i18n.ts.search }}</MkButton> + + <MkFoldableSection v-if="searched"> + <template #header>{{ i18n.ts.searchResult }}</template> + + <div class="_gaps_s"> + <div v-for="message in searchResults" :key="message.id" :class="$style.searchResultItem"> + <XMessage :message="message" :isSearchResult="true"/> + </div> + </div> + </MkFoldableSection> + + <MkFoldableSection> + <template #header>{{ i18n.ts._chat.history }}</template> + + <div v-if="history.length > 0" class="_gaps_s"> + <MkA + v-for="item in history" + :key="item.id" + :class="[$style.message, { [$style.isMe]: item.isMe, [$style.isRead]: item.message.isRead }]" + class="_panel" + :to="item.message.toRoomId ? `/chat/room/${item.message.toRoomId}` : `/chat/user/${item.other!.id}`" + > + <MkAvatar v-if="item.other" :class="$style.messageAvatar" :user="item.other" indicator :preview="false"/> + <div :class="$style.messageBody"> + <header v-if="item.message.toRoom" :class="$style.messageHeader"> + <span :class="$style.messageHeaderName">{{ item.message.toRoom.name }}</span> + <MkTime :time="item.message.createdAt" :class="$style.messageHeaderTime"/> + </header> + <header v-else :class="$style.messageHeader"> + <MkUserName :class="$style.messageHeaderName" :user="item.other!"/> + <MkAcct :class="$style.messageHeaderUsername" :user="item.other!"/> + <MkTime :time="item.message.createdAt" :class="$style.messageHeaderTime"/> + </header> + <div :class="$style.messageBodyText"><span v-if="item.isMe" :class="$style.youSaid">{{ i18n.ts.you }}:</span>{{ item.message.text }}</div> + </div> + </MkA> + </div> + <div v-if="!fetching && history.length == 0" class="_fullinfo"> + <div>{{ i18n.ts._chat.noHistory }}</div> + </div> + <MkLoading v-if="fetching"/> + </MkFoldableSection> +</div> +</template> + +<script lang="ts" setup> +import { computed, onMounted, 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 { misskeyApi } from '@/utility/misskey-api.js'; +import { ensureSignin } from '@/i.js'; +import { useRouter } from '@/router.js'; +import * as os from '@/os.js'; +import { updateCurrentAccountPartial } from '@/accounts.js'; +import MkInput from '@/components/MkInput.vue'; +import MkFoldableSection from '@/components/MkFoldableSection.vue'; + +const $i = ensureSignin(); + +const router = useRouter(); + +const fetching = ref(true); +const history = ref<{ + id: string; + message: Misskey.entities.ChatMessage; + other: Misskey.entities.ChatMessage['fromUser'] | Misskey.entities.ChatMessage['toUser'] | null; + isMe: boolean; +}[]>([]); + +const searchQuery = ref(''); +const searched = ref(false); +const searchResults = ref<Misskey.entities.ChatMessage[]>([]); + +function start(ev: MouseEvent) { + os.popupMenu([{ + text: i18n.ts._chat.individualChat, + caption: i18n.ts._chat.individualChat_description, + icon: 'ti ti-user', + action: () => { startUser(); }, + }, { type: 'divider' }, { + type: 'parent', + text: i18n.ts._chat.roomChat, + caption: i18n.ts._chat.roomChat_description, + icon: 'ti ti-users-group', + children: [{ + text: i18n.ts._chat.createRoom, + icon: 'ti ti-plus', + action: () => { createRoom(); }, + }], + }], ev.currentTarget ?? ev.target); +} + +async function startUser() { + os.selectUser().then(user => { + router.push(`/chat/user/${user.id}`); + }); +} + +async function createRoom() { + const { canceled, result } = await os.inputText({ + title: i18n.ts.name, + minLength: 1, + }); + if (canceled) return; + + const room = await misskeyApi('chat/rooms/create', { + name: result, + }); + + router.push(`/chat/room/${room.id}`); +} + +async function search() { + const res = await misskeyApi('chat/messages/search', { + query: searchQuery.value, + }); + + searchResults.value = res; + searched.value = true; +} + +async function fetchHistory() { + fetching.value = true; + + const [userMessages, roomMessages] = await Promise.all([ + misskeyApi('chat/history', { room: false }), + misskeyApi('chat/history', { room: true }), + ]); + + history.value = [...userMessages, ...roomMessages] + .toSorted((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + .map(m => ({ + id: m.id, + message: m, + other: m.room == null ? (m.fromUserId === $i.id ? m.toUser : m.fromUser) : null, + isMe: m.fromUserId === $i.id, + })); + + fetching.value = false; + + updateCurrentAccountPartial({ hasUnreadChatMessages: false }); +} + +onMounted(() => { + fetchHistory(); +}); +</script> + +<style lang="scss" module> +.start { + margin: 0 auto; +} + +.message { + position: relative; + display: flex; + padding: 16px 24px; + + &.isRead, + &.isMe { + opacity: 0.8; + } + + &:not(.isMe):not(.isRead) { + &::before { + content: ''; + position: absolute; + top: 8px; + right: 8px; + width: 8px; + height: 8px; + border-radius: 100%; + background-color: var(--MI_THEME-accent); + } + } +} + +.messageAvatar { + width: 50px; + height: 50px; + margin: 0 16px 0 0; +} + +.messageBody { + flex: 1; + min-width: 0; +} + +.messageHeader { + display: flex; + align-items: center; + margin-bottom: 2px; + white-space: nowrap; + overflow: clip; +} + +.messageHeaderName { + margin: 0; + padding: 0; + overflow: hidden; + text-overflow: ellipsis; + font-size: 1em; + font-weight: bold; +} + +.messageHeaderUsername { + margin: 0 8px; +} + +.messageHeaderTime { + margin-left: auto; +} + +.messageBodyText { + overflow: hidden; + overflow-wrap: break-word; + font-size: 1.1em; +} + +.youSaid { + font-weight: bold; + margin-right: 0.5em; +} + +.searchResultItem { + padding: 12px; + border: solid 1px var(--MI_THEME-divider); + border-radius: 12px; +} +</style> diff --git a/packages/frontend/src/pages/chat/home.invitations.vue b/packages/frontend/src/pages/chat/home.invitations.vue new file mode 100644 index 0000000000..4c3c0b282e --- /dev/null +++ b/packages/frontend/src/pages/chat/home.invitations.vue @@ -0,0 +1,98 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div class="_gaps"> + <div v-if="invitations.length > 0" class="_gaps_s"> + <MkFolder v-for="invitation in invitations" :key="invitation.id" :defaultOpen="true"> + <template #icon><i class="ti ti-users-group"></i></template> + <template #label>{{ invitation.room.name }}</template> + <template #suffix><MkTime :time="invitation.createdAt"/></template> + <template #footer> + <div class="_buttons"> + <MkButton primary @click="join(invitation)"><i class="ti ti-plus"></i> {{ i18n.ts._chat.join }}</MkButton> + <MkButton danger @click="ignore(invitation)"><i class="ti ti-x"></i> {{ i18n.ts._chat.ignore }}</MkButton> + </div> + </template> + + <div :class="$style.invitationBody"> + <MkAvatar :user="invitation.room.owner" :class="$style.invitationBodyAvatar" link/> + <div style="flex: 1;" class="_gaps_s"> + <MkUserName :user="invitation.room.owner"/> + <hr> + <div>{{ invitation.room.description === '' ? i18n.ts.noDescription : invitation.room.description }}</div> + </div> + </div> + </MkFolder> + </div> + <div v-if="!fetching && invitations.length == 0" class="_fullinfo"> + <div>{{ i18n.ts._chat.noInvitations }}</div> + </div> + <MkLoading v-if="fetching"/> +</div> +</template> + +<script lang="ts" setup> +import { computed, 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); +const invitations = ref<Misskey.entities.ChatRoomInvitation[]>([]); + +async function fetchInvitations() { + fetching.value = true; + + const res = await misskeyApi('chat/rooms/invitations/inbox', { + }); + + invitations.value = res; + + fetching.value = false; +} + +async function join(invitation: Misskey.entities.ChatRoomInvitation) { + await misskeyApi('chat/rooms/join', { + roomId: invitation.room.id, + }); + + router.push(`/chat/room/${invitation.room.id}`); +} + +async function ignore(invitation: Misskey.entities.ChatRoomInvitation) { + await misskeyApi('chat/rooms/invitations/ignore', { + roomId: invitation.room.id, + }); + + invitations.value = invitations.value.filter(i => i.id !== invitation.id); +} + +onMounted(() => { + fetchInvitations(); +}); +</script> + +<style lang="scss" module> +.invitationBody { + display: flex; + align-items: center; +} + +.invitationBodyAvatar { + margin-right: 12px; + width: 45px; + height: 45px; +} +</style> diff --git a/packages/frontend/src/pages/chat/home.joiningRooms.vue b/packages/frontend/src/pages/chat/home.joiningRooms.vue new file mode 100644 index 0000000000..63e4d2adf8 --- /dev/null +++ b/packages/frontend/src/pages/chat/home.joiningRooms.vue @@ -0,0 +1,54 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +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"/> + </div> + <div v-if="!fetching && memberships.length == 0" class="_fullinfo"> + <div>{{ i18n.ts._chat.noRooms }}</div> + </div> + <MkLoading v-if="fetching"/> +</div> +</template> + +<script lang="ts" setup> +import { computed, 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[]>([]); + +async function fetchRooms() { + fetching.value = true; + + const res = await misskeyApi('chat/rooms/joining', { + }); + + memberships.value = res; + + fetching.value = false; +} + +onMounted(() => { + fetchRooms(); +}); +</script> + +<style lang="scss" module> + +</style> diff --git a/packages/frontend/src/pages/chat/home.ownedRooms.vue b/packages/frontend/src/pages/chat/home.ownedRooms.vue new file mode 100644 index 0000000000..b0449fb373 --- /dev/null +++ b/packages/frontend/src/pages/chat/home.ownedRooms.vue @@ -0,0 +1,54 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div class="_gaps"> + <div v-if="rooms.length > 0" class="_gaps_s"> + <XRoom v-for="room in rooms" :key="room.id" :room="room"/> + </div> + <div v-if="!fetching && rooms.length == 0" class="_fullinfo"> + <div>{{ i18n.ts._chat.noRooms }}</div> + </div> + <MkLoading v-if="fetching"/> +</div> +</template> + +<script lang="ts" setup> +import { computed, 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[]>([]); + +async function fetchRooms() { + fetching.value = true; + + const res = await misskeyApi('chat/rooms/owned', { + }); + + rooms.value = res; + + fetching.value = false; +} + +onMounted(() => { + fetchRooms(); +}); +</script> + +<style lang="scss" module> + +</style> diff --git a/packages/frontend/src/pages/chat/home.vue b/packages/frontend/src/pages/chat/home.vue new file mode 100644 index 0000000000..c2b272a42d --- /dev/null +++ b/packages/frontend/src/pages/chat/home.vue @@ -0,0 +1,60 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> + <MkPolkadots v-if="tab === 'home'" accented/> + <MkSpacer :contentMax="700"> + <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> + <XHome v-if="tab === 'home'"/> + <XInvitations v-else-if="tab === 'invitations'"/> + <XJoiningRooms v-else-if="tab === 'joiningRooms'"/> + <XOwnedRooms v-else-if="tab === 'ownedRooms'"/> + </MkHorizontalSwipe> + </MkSpacer> +</PageWithHeader> +</template> + +<script lang="ts" setup> +import { computed, onMounted, ref } from 'vue'; +import XHome from './home.home.vue'; +import XInvitations from './home.invitations.vue'; +import XJoiningRooms from './home.joiningRooms.vue'; +import XOwnedRooms from './home.ownedRooms.vue'; +import { i18n } from '@/i18n.js'; +import { definePage } from '@/page.js'; +import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; +import MkPolkadots from '@/components/MkPolkadots.vue'; + +const tab = ref('home'); + +const headerActions = computed(() => []); + +const headerTabs = computed(() => [{ + key: 'home', + title: i18n.ts._chat.home, + icon: 'ti ti-home', +}, { + key: 'invitations', + title: i18n.ts._chat.invitations, + icon: 'ti ti-ticket', +}, { + key: 'joiningRooms', + title: i18n.ts._chat.joiningRooms, + icon: 'ti ti-users-group', +}, { + key: 'ownedRooms', + title: i18n.ts._chat.yourRooms, + icon: 'ti ti-settings', +}]); + +definePage(() => ({ + title: i18n.ts.chat + ' (beta)', + icon: 'ti ti-message', +})); +</script> + +<style lang="scss" module> +</style> diff --git a/packages/frontend/src/pages/chat/message.vue b/packages/frontend/src/pages/chat/message.vue new file mode 100644 index 0000000000..be8be7e5d1 --- /dev/null +++ b/packages/frontend/src/pages/chat/message.vue @@ -0,0 +1,55 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<PageWithHeader> + <MkSpacer :contentMax="700"> + <div v-if="initializing"> + <MkLoading/> + </div> + <div v-else> + <XMessage :message="message"/> + </div> + </MkSpacer> +</PageWithHeader> +</template> + +<script lang="ts" setup> +import { ref, useTemplateRef, computed, watch, onMounted, nextTick, onBeforeUnmount, onDeactivated, onActivated } 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>(); + +async function initialize() { + initializing.value = true; + + message.value = await misskeyApi('chat/messages/show', { + messageId: props.messageId, + }); + + initializing.value = false; +} + +onMounted(() => { + initialize(); +}); + +definePage({ + title: i18n.ts.chat, +}); +</script> diff --git a/packages/frontend/src/pages/chat/room.form.vue b/packages/frontend/src/pages/chat/room.form.vue new file mode 100644 index 0000000000..aba9d6061f --- /dev/null +++ b/packages/frontend/src/pages/chat/room.form.vue @@ -0,0 +1,333 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div + :class="$style.root" + @dragover.stop="onDragover" + @drop.stop="onDrop" +> + <textarea + ref="textareaEl" + v-model="text" + :class="$style.textarea" + class="_acrylic" + :placeholder="i18n.ts.inputMessageHere" + :readonly="textareaReadOnly" + @keydown="onKeydown" + @paste="onPaste" + ></textarea> + <footer :class="$style.footer"> + <div v-if="file" :class="$style.file" @click="file = null">{{ file.name }}</div> + <div :class="$style.buttons"> + <button class="_button" :class="$style.button" @click="chooseFile"><i class="ti ti-photo-plus"></i></button> + <button class="_button" :class="$style.button" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button> + <button class="_button" :class="[$style.button, $style.send]" :disabled="!canSend || sending" :title="i18n.ts.send" @click="send"> + <template v-if="!sending"><i class="ti ti-send"></i></template><template v-if="sending"><MkLoading :em="true"/></template> + </button> + </div> + </footer> + <input ref="fileEl" style="display: none;" type="file" @change="onChangeFile"/> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, watch, ref, shallowRef, computed, nextTick, readonly } 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'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { prefer } from '@/preferences.js'; +import { Autocomplete } from '@/utility/autocomplete.js'; +import { emojiPicker } from '@/utility/emoji-picker.js'; + +const props = defineProps<{ + user?: Misskey.entities.UserDetailed | null; + room?: Misskey.entities.ChatRoom | null; +}>(); + +const textareaEl = shallowRef<HTMLTextAreaElement>(); +const fileEl = shallowRef<HTMLInputElement>(); + +const text = ref<string>(''); +const file = ref<Misskey.entities.DriveFile | null>(null); +const sending = ref(false); +const textareaReadOnly = ref(false); + +const canSend = computed(() => (text.value != null && text.value !== '') || file.value != null); + +function getDraftKey() { + return props.user ? 'user:' + props.user.id : 'room:' + props.room?.id; +} + +watch([text, file], saveDraft); + +async function onPaste(ev: ClipboardEvent) { + if (!ev.clipboardData) return; + + const pastedFileName = 'yyyy-MM-dd HH-mm-ss [{{number}}]'; + + const clipboardData = ev.clipboardData; + const items = clipboardData.items; + + if (items.length === 1) { + if (items[0].kind === 'file') { + const pastedFile = items[0].getAsFile(); + if (!pastedFile) return; + const lio = pastedFile.name.lastIndexOf('.'); + const ext = lio >= 0 ? pastedFile.name.slice(lio) : ''; + const formatted = formatTimeString(new Date(pastedFile.lastModified), pastedFileName).replace(/{{number}}/g, '1') + ext; + if (formatted) upload(pastedFile, formatted); + } + } else { + if (items[0].kind === 'file') { + os.alert({ + type: 'error', + text: i18n.ts.onlyOneFileCanBeAttached, + }); + } + } +} + +function onDragover(ev: DragEvent) { + if (!ev.dataTransfer) return; + + const isFile = ev.dataTransfer.items[0].kind === 'file'; + const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_; + if (isFile || isDriveFile) { + ev.preventDefault(); + switch (ev.dataTransfer.effectAllowed) { + case 'all': + case 'uninitialized': + case 'copy': + case 'copyLink': + case 'copyMove': + ev.dataTransfer.dropEffect = 'copy'; + break; + case 'linkMove': + case 'move': + ev.dataTransfer.dropEffect = 'move'; + break; + default: + ev.dataTransfer.dropEffect = 'none'; + break; + } + } +} + +function onDrop(ev: DragEvent): void { + if (!ev.dataTransfer) return; + + // ファイルだったら + if (ev.dataTransfer.files.length === 1) { + ev.preventDefault(); + upload(ev.dataTransfer.files[0]); + return; + } else if (ev.dataTransfer.files.length > 1) { + ev.preventDefault(); + os.alert({ + type: 'error', + text: i18n.ts.onlyOneFileCanBeAttached, + }); + return; + } + + //#region ドライブのファイル + const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); + if (driveFile != null && driveFile !== '') { + file.value = JSON.parse(driveFile); + ev.preventDefault(); + } + //#endregion +} + +function onKeydown(ev: KeyboardEvent) { + if ((ev.key === 'Enter') && (ev.ctrlKey || ev.metaKey)) { + send(); + } +} + +function chooseFile(ev: MouseEvent) { + selectFile(ev.currentTarget ?? ev.target, i18n.ts.selectFile).then(selectedFile => { + file.value = selectedFile; + }); +} + +function onChangeFile() { + if (fileEl.value.files![0]) upload(fileEl.value.files[0]); +} + +function upload(fileToUpload: File, name?: string) { + uploadFile(fileToUpload, prefer.s.uploadFolder, name).then(res => { + file.value = res; + }); +} + +function send() { + if (!canSend.value) return; + + sending.value = true; + + if (props.user) { + misskeyApi('chat/messages/create-to-user', { + toUserId: props.user.id, + text: text.value ? text.value : undefined, + fileId: file.value ? file.value.id : undefined, + }).then(message => { + clear(); + }).catch(err => { + console.error(err); + }).then(() => { + sending.value = false; + }); + } else if (props.room) { + misskeyApi('chat/messages/create-to-room', { + toRoomId: props.room.id, + text: text.value ? text.value : undefined, + fileId: file.value ? file.value.id : undefined, + }).then(message => { + clear(); + }).catch(err => { + console.error(err); + }).then(() => { + sending.value = false; + }); + } +} + +function clear() { + text.value = ''; + file.value = null; + deleteDraft(); +} + +function saveDraft() { + const drafts = JSON.parse(miLocalStorage.getItem('chatMessageDrafts') || '{}'); + + drafts[getDraftKey()] = { + updatedAt: new Date(), + data: { + text: text.value, + file: file.value, + }, + }; + + miLocalStorage.setItem('chatMessageDrafts', JSON.stringify(drafts)); +} + +function deleteDraft() { + const drafts = JSON.parse(miLocalStorage.getItem('chatMessageDrafts') || '{}'); + + delete drafts[getDraftKey()]; + + miLocalStorage.setItem('chatMessageDrafts', JSON.stringify(drafts)); +} + +async function insertEmoji(ev: MouseEvent) { + textareaReadOnly.value = true; + const target = ev.currentTarget ?? ev.target; + if (target == null) return; + + // emojiPickerはダイアログが閉じずにtextareaとやりとりするので、 + // focustrapをかけているとinsertTextAtCursorが効かない + // そのため、投稿フォームのテキストに直接注入する + // See: https://github.com/misskey-dev/misskey/pull/14282 + // https://github.com/misskey-dev/misskey/issues/14274 + + let pos = textareaEl.value?.selectionStart ?? 0; + let posEnd = textareaEl.value?.selectionEnd ?? text.value.length; + emojiPicker.show( + target as HTMLElement, + emoji => { + const textBefore = text.value.substring(0, pos); + const textAfter = text.value.substring(posEnd); + text.value = textBefore + emoji + textAfter; + pos += emoji.length; + posEnd += emoji.length; + }, + () => { + textareaReadOnly.value = false; + nextTick(() => focus()); + }, + ); +} + +onMounted(() => { + // TODO: detach when unmount + new Autocomplete(textareaEl.value, text); + + // 書きかけの投稿を復元 + const draft = JSON.parse(miLocalStorage.getItem('chatMessageDrafts') || '{}')[getDraftKey()]; + if (draft) { + text.value = draft.data.text; + file.value = draft.data.file; + } +}); +</script> + +<style lang="scss" module> +.root { + position: relative; + border-bottom: none; + border-radius: 14px 14px 0 0; + overflow: clip; +} + +.textarea { + cursor: auto; + display: block; + width: 100%; + min-width: 100%; + max-width: 100%; + min-height: 80px; + margin: 0; + padding: 16px 16px 0 16px; + resize: none; + font-size: 1em; + font-family: inherit; + outline: none; + border: none; + border-radius: 0; + box-shadow: none; + box-sizing: border-box; + color: var(--MI_THEME-fg); + field-sizing: content; +} + +.footer { + position: sticky; + bottom: 0; + background: var(--MI_THEME-panel); +} + +.file { + padding: 8px; + cursor: pointer; +} + +.buttons { + display: flex; +} + +.button { + height: 50px; + aspect-ratio: 1; + + &:hover { + color: var(--MI_THEME-accent); + } +} +.send { + margin-left: auto; + color: var(--MI_THEME-accent); +} +</style> diff --git a/packages/frontend/src/pages/chat/room.info.vue b/packages/frontend/src/pages/chat/room.info.vue new file mode 100644 index 0000000000..7d38d07b3a --- /dev/null +++ b/packages/frontend/src/pages/chat/room.info.vue @@ -0,0 +1,87 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div class="_gaps"> + <MkInput v-model="name_" :disabled="!isOwner"> + <template #label>{{ i18n.ts.name }}</template> + </MkInput> + + <MkTextarea v-model="description_" :disabled="!isOwner"> + <template #label>{{ i18n.ts.description }}</template> + </MkTextarea> + + <MkButton v-if="isOwner" primary @click="save">{{ i18n.ts.save }}</MkButton> + + <hr> + + <MkSwitch v-if="!isOwner" v-model="isMuted"> + <template #label>{{ i18n.ts._chat.muteThisRoom }}</template> + </MkSwitch> +</div> +</template> + +<script lang="ts" setup> +import { computed, onMounted, 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'; +import MkTextarea from '@/components/MkTextarea.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; + +const $i = ensureSignin(); + +const props = defineProps<{ + room: Misskey.entities.ChatRoom; +}>(); + +const isOwner = computed(() => { + return props.room.ownerId === $i.id; +}); + +const name_ = ref(props.room.name); +const description_ = ref(props.room.description); + +function save() { + os.apiWithDialog('chat/rooms/update', { + roomId: props.room.id, + name: name_.value, + description: description_.value, + }); +} + +const isMuted = ref(props.room.isMuted); + +watch(isMuted, async () => { + await os.apiWithDialog('chat/rooms/mute', { + roomId: props.room.id, + mute: isMuted.value, + }); +}); + +onMounted(async () => { + +}); +</script> + +<style lang="scss" module> +.membership { + display: flex; +} + +.membershipBody { + flex: 1; + min-width: 0; + margin-right: 8px; + + &:hover { + text-decoration: none; + } +} +</style> diff --git a/packages/frontend/src/pages/chat/room.members.vue b/packages/frontend/src/pages/chat/room.members.vue new file mode 100644 index 0000000000..d20216a81c --- /dev/null +++ b/packages/frontend/src/pages/chat/room.members.vue @@ -0,0 +1,73 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div class="_gaps"> + <MkButton v-if="isOwner" primary rounded style="margin: 0 auto;" @click="emit('inviteUser')"><i class="ti ti-plus"></i> {{ i18n.ts._chat.inviteUser }}</MkButton> + + <MkA :class="$style.membershipBody" :to="`${userPage(room.owner)}`"> + <MkUserCardMini :user="room.owner"/> + </MkA> + + <hr> + + <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> + </div> +</div> +</template> + +<script lang="ts" setup> +import { computed, 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 * as os from '@/os.js'; +import MkUserCardMini from '@/components/MkUserCardMini.vue'; +import { userPage } from '@/filters/user.js'; +import { ensureSignin } from '@/i.js'; + +const $i = ensureSignin(); + +const props = defineProps<{ + room: Misskey.entities.ChatRoom; +}>(); + +const emit = defineEmits<{ + (ev: 'inviteUser'): void, +}>(); + +const isOwner = computed(() => { + return props.room.ownerId === $i.id; +}); + +const memberships = ref<Misskey.entities.ChatRoomMembership[]>([]); + +onMounted(async () => { + memberships.value = await misskeyApi('chat/rooms/members', { + roomId: props.room.id, + limit: 50, + }); +}); +</script> + +<style lang="scss" module> +.membership { + display: flex; +} + +.membershipBody { + flex: 1; + min-width: 0; + margin-right: 8px; + + &:hover { + text-decoration: none; + } +} +</style> diff --git a/packages/frontend/src/pages/chat/room.search.vue b/packages/frontend/src/pages/chat/room.search.vue new file mode 100644 index 0000000000..de5e7156ca --- /dev/null +++ b/packages/frontend/src/pages/chat/room.search.vue @@ -0,0 +1,68 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div class="_gaps"> + <MkInput + v-model="searchQuery" + :placeholder="i18n.ts._chat.searchMessages" + type="search" + > + <template #prefix><i class="ti ti-search"></i></template> + </MkInput> + + <MkButton v-if="searchQuery.length > 0" primary rounded @click="search">{{ i18n.ts.search }}</MkButton> + + <MkFoldableSection v-if="searched"> + <template #header>{{ i18n.ts.searchResult }}</template> + + <div class="_gaps_s"> + <div v-for="message in searchResults" :key="message.id" :class="$style.searchResultItem"> + <XMessage :message="message" :user="message.fromUser" :isSearchResult="true"/> + </div> + </div> + </MkFoldableSection> +</div> +</template> + +<script lang="ts" setup> +import { computed, onMounted, 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 { misskeyApi } from '@/utility/misskey-api.js'; +import * as os from '@/os.js'; +import MkInput from '@/components/MkInput.vue'; +import MkFoldableSection from '@/components/MkFoldableSection.vue'; + +const props = defineProps<{ + userId?: string; + roomId?: string; +}>(); + +const searchQuery = ref(''); +const searched = ref(false); +const searchResults = ref<Misskey.entities.ChatMessage[]>([]); + +async function search() { + const res = await misskeyApi('chat/messages/search', { + query: searchQuery.value, + roomId: props.roomId, + userId: props.userId, + }); + + searchResults.value = res; + searched.value = true; +} +</script> + +<style lang="scss" module> +.searchResultItem { + padding: 12px; + border: solid 1px var(--MI_THEME-divider); + border-radius: 12px; +} +</style> diff --git a/packages/frontend/src/pages/chat/room.vue b/packages/frontend/src/pages/chat/room.vue new file mode 100644 index 0000000000..15e9f43db2 --- /dev/null +++ b/packages/frontend/src/pages/chat/room.vue @@ -0,0 +1,426 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<PageWithHeader v-model:tab="tab" :reversed="tab === 'chat'" :tabs="headerTabs" :actions="headerActions"> + <MkSpacer v-if="tab === 'chat'" :contentMax="700"> + <div v-if="initializing"> + <MkLoading/> + </div> + + <div v-else-if="messages.length === 0"> + <div class="_gaps" style="text-align: center;"> + <div>{{ i18n.ts._chat.noMessagesYet }}</div> + <template v-if="user"> + <div v-if="user.chatScope === 'followers'">{{ i18n.ts._chat.thisUserAllowsChatOnlyFromFollowers }}</div> + <div v-else-if="user.chatScope === 'following'">{{ i18n.ts._chat.thisUserAllowsChatOnlyFromFollowing }}</div> + <div v-else-if="user.chatScope === 'mutual'">{{ i18n.ts._chat.thisUserAllowsChatOnlyFromMutualFollowing }}</div> + <div v-else>{{ i18n.ts._chat.thisUserNotAllowedChatAnyone }}</div> + </template> + <template v-else-if="room"> + <div>{{ i18n.ts._chat.inviteUserToChat }}</div> + </template> + </div> + </div> + + <div v-else class="_gaps"> + <div v-if="canFetchMore"> + <MkButton :class="$style.more" :wait="moreFetching" primary rounded @click="fetchMore">{{ i18n.ts.loadMore }}</MkButton> + </div> + + <TransitionGroup + :enterActiveClass="prefer.s.animation ? $style.transition_x_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_x_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_x_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_x_leaveTo : ''" + :moveClass="prefer.s.animation ? $style.transition_x_move : ''" + tag="div" class="_gaps" + > + <XMessage v-for="message in messages.toReversed()" :key="message.id" :message="message"/> + </TransitionGroup> + </div> + </MkSpacer> + + <MkSpacer v-else-if="tab === 'search'" :contentMax="700"> + <XSearch :userId="userId" :roomId="roomId"/> + </MkSpacer> + + <MkSpacer v-else-if="tab === 'members'" :contentMax="700"> + <XMembers v-if="room != null" :room="room" @inviteUser="inviteUser"/> + </MkSpacer> + + <MkSpacer v-else-if="tab === 'info'" :contentMax="700"> + <XInfo v-if="room != null" :room="room"/> + </MkSpacer> + + <template #footer> + <div v-if="tab === 'chat'" :class="$style.footer"> + <div class="_gaps"> + <Transition name="fade"> + <div v-show="showIndicator" :class="$style.new"> + <button class="_buttonPrimary" :class="$style.newButton" @click="onIndicatorClick"> + <i class="fas ti-fw fa-arrow-circle-down" :class="$style.newIcon"></i>{{ i18n.ts.newMessageExists }} + </button> + </div> + </Transition> + <XForm v-if="!initializing" :user="user" :room="room" :class="$style.form"/> + </div> + </div> + </template> +</PageWithHeader> +</template> + +<script lang="ts" setup> +import { ref, useTemplateRef, computed, watch, onMounted, nextTick, onBeforeUnmount, onDeactivated, onActivated } from 'vue'; +import * as Misskey from 'misskey-js'; +import { isTailVisible } 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 * as os from '@/os.js'; +import { useStream } from '@/stream.js'; +import * as sound from '@/utility/sound.js'; +import { i18n } from '@/i18n.js'; +import { ensureSignin } from '@/i.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { definePage } from '@/page.js'; +import { prefer } from '@/preferences.js'; +import MkButton from '@/components/MkButton.vue'; +import { useRouter } from '@/router.js'; + +const $i = ensureSignin(); +const router = useRouter(); + +const props = defineProps<{ + userId?: string; + roomId?: string; +}>(); + +const initializing = ref(true); +const moreFetching = ref(false); +const messages = ref<Misskey.entities.ChatMessage[]>([]); +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 showIndicator = ref(false); + +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; + } + } + + return { + ...message, + fromUser: message.fromUser ?? (message.fromUserId === $i.id ? $i : user), + reactions, + }; +} + +async function initialize() { + const LIMIT = 20; + + initializing.value = true; + + if (props.userId) { + const [u, m] = await Promise.all([ + misskeyApi('users/show', { userId: props.userId }), + misskeyApi('chat/messages/user-timeline', { userId: props.userId, limit: LIMIT }), + ]); + + user.value = u; + messages.value = m.map(x => normalizeMessage(x)); + + if (messages.value.length === LIMIT) { + canFetchMore.value = true; + } + + connection.value = useStream().useChannel('chatUser', { + otherId: user.value.id, + }); + connection.value.on('message', onMessage); + connection.value.on('deleted', onDeleted); + connection.value.on('react', onReact); + } else { + const [r, m] = await Promise.all([ + misskeyApi('chat/rooms/show', { roomId: props.roomId }), + misskeyApi('chat/messages/room-timeline', { roomId: props.roomId, limit: LIMIT }), + ]); + + room.value = r; + messages.value = m.map(x => normalizeMessage(x)); + + if (messages.value.length === LIMIT) { + canFetchMore.value = true; + } + + connection.value = useStream().useChannel('chatRoom', { + roomId: room.value.id, + }); + connection.value.on('message', onMessage); + connection.value.on('deleted', onDeleted); + connection.value.on('react', onReact); + } + + window.document.addEventListener('visibilitychange', onVisibilitychange); + + initializing.value = false; +} + +let isActivated = true; + +onActivated(() => { + isActivated = true; +}); + +onDeactivated(() => { + isActivated = false; +}); + +async function fetchMore() { + const LIMIT = 30; + + moreFetching.value = true; + + const newMessages = props.userId ? await misskeyApi('chat/messages/user-timeline', { + userId: user.value.id, + limit: LIMIT, + untilId: messages.value[messages.value.length - 1].id, + }) : await misskeyApi('chat/messages/room-timeline', { + roomId: room.value.id, + limit: LIMIT, + untilId: messages.value[messages.value.length - 1].id, + }); + + messages.value.push(...newMessages.map(x => normalizeMessage(x))); + + canFetchMore.value = newMessages.length === LIMIT; + moreFetching.value = false; +} + +function onMessage(message: Misskey.entities.ChatMessage) { + sound.playMisskeySfx('chatMessage'); + + messages.value.unshift(normalizeMessage(message)); + + // TODO: DOM的にバックグラウンドになっていないかどうかも考慮する + if (message.fromUserId !== $i.id && !window.document.hidden && isActivated) { + connection.value?.send('read', { + id: message.id, + }); + } + + if (message.fromUserId !== $i.id) { + //notifyNewMessage(); + } +} + +function onDeleted(id) { + const index = messages.value.findIndex(m => m.id === id); + if (index !== -1) { + messages.value.splice(index, 1); + } +} + +function onReact(ctx) { + 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, + }); + } else { + message.reactions.push({ + reaction: ctx.reaction, + user: ctx.user, + }); + } + } +} + +function onIndicatorClick() { + showIndicator.value = false; +} + +function notifyNewMessage() { + showIndicator.value = true; +} + +function onVisibilitychange() { + if (window.document.hidden) return; + // TODO +} + +onMounted(() => { + initialize(); +}); + +onBeforeUnmount(() => { + connection.value?.dispose(); + window.document.removeEventListener('visibilitychange', onVisibilitychange); +}); + +async function inviteUser() { + const invitee = await os.selectUser({ includeSelf: false, localOnly: true }); + os.apiWithDialog('chat/rooms/invitations/create', { + roomId: room.value?.id, + userId: invitee.id, + }); +} + +async function leaveRoom() { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts.areYouSure, + }); + if (canceled) return; + + misskeyApi('chat/rooms/leave', { + roomId: room.value?.id, + }); + router.push('/chat'); +} + +function showMenu(ev: MouseEvent) { + const menuItems: MenuItem[] = []; + + if (room.value) { + if (room.value.ownerId === $i.id) { + menuItems.push({ + text: i18n.ts._chat.inviteUser, + icon: 'ti ti-user-plus', + action: () => { + inviteUser(); + }, + }); + } else { + menuItems.push({ + text: i18n.ts._chat.leave, + icon: 'ti ti-x', + action: () => { + leaveRoom(); + }, + }); + } + } + + os.popupMenu(menuItems, ev.currentTarget ?? ev.target); +} + +const tab = ref('chat'); + +const headerTabs = computed(() => room.value ? [{ + key: 'chat', + title: i18n.ts.chat, + icon: 'ti ti-messages', +}, { + key: 'members', + title: i18n.ts._chat.members, + icon: 'ti ti-users', +}, { + key: 'search', + title: i18n.ts.search, + icon: 'ti ti-search', +}, { + key: 'info', + title: i18n.ts.info, + icon: 'ti ti-info-circle', +}] : [{ + key: 'chat', + title: i18n.ts.chat, + icon: 'ti ti-messages', +}, { + key: 'search', + title: i18n.ts.search, + icon: 'ti ti-search', +}]); + +const headerActions = computed(() => [{ + icon: 'ti ti-dots', + handler: showMenu, +}]); + +definePage(computed(() => !initializing.value ? user.value ? { + userName: user, + avatar: user, +} : { + title: room.value?.name, + icon: 'ti ti-users', +} : null)); +</script> + +<style lang="scss" module> +.transition_x_move, +.transition_x_enterActive, +.transition_x_leaveActive { + transition: opacity 0.2s cubic-bezier(0,.5,.5,1), transform 0.2s cubic-bezier(0,.5,.5,1) !important; +} +.transition_x_enterFrom, +.transition_x_leaveTo { + opacity: 0; + transform: translateY(80px); +} +.transition_x_leaveActive { + position: absolute; +} + +.root { +} + +.more { + margin: 0 auto; +} + +.footer { + width: 100%; + padding-top: 8px; +} + +.new { + width: 100%; + padding-bottom: 8px; + text-align: center; +} + +.newButton { + display: inline-block; + margin: 0; + padding: 0 12px; + line-height: 32px; + font-size: 12px; + border-radius: 16px; +} + +.newIcon { + display: inline-block; + margin-right: 8px; +} + +.footer { + +} + +.form { + margin: 0 auto; + width: 100%; + max-width: 700px; +} + +.fade-enter-active, .fade-leave-active { + transition: opacity 0.1s; +} + +.fade-enter-from, .fade-leave-to { + transition: opacity 0.5s; + opacity: 0; +} +</style> diff --git a/packages/frontend/src/pages/settings/notifications.vue b/packages/frontend/src/pages/settings/notifications.vue index 530b63b701..93a41e9ddd 100644 --- a/packages/frontend/src/pages/settings/notifications.vue +++ b/packages/frontend/src/pages/settings/notifications.vue @@ -38,7 +38,6 @@ SPDX-License-Identifier: AGPL-3.0-only <FormSection> <div class="_gaps_m"> <FormLink @click="readAllNotifications">{{ i18n.ts.markAsReadAllNotifications }}</FormLink> - <FormLink @click="readAllUnreadNotes">{{ i18n.ts.markAsReadAllUnreadNotes }}</FormLink> </div> </FormSection> <FormSection> @@ -93,10 +92,6 @@ const pushRegistrationInServer = computed(() => allowButton.value?.pushRegistrat const sendReadMessage = computed(() => pushRegistrationInServer.value?.sendReadMessage || false); const userLists = await misskeyApi('users/lists/list'); -async function readAllUnreadNotes() { - await os.apiWithDialog('i/read-all-unread-notes'); -} - async function readAllNotifications() { await os.apiWithDialog('notifications/mark-all-as-read'); } diff --git a/packages/frontend/src/pages/settings/privacy.vue b/packages/frontend/src/pages/settings/privacy.vue index f6eb203095..2f8a697d74 100644 --- a/packages/frontend/src/pages/settings/privacy.vue +++ b/packages/frontend/src/pages/settings/privacy.vue @@ -78,6 +78,20 @@ SPDX-License-Identifier: AGPL-3.0-only </MkSwitch> </SearchMarker> + <FormSection> + <SearchMarker :keywords="['chat']"> + <MkSelect v-model="chatScope" @update:modelValue="save()"> + <template #label><SearchLabel>{{ i18n.ts._chat.chatAllowedUsers }}</SearchLabel></template> + <option value="everyone">{{ i18n.ts._chat._chatAllowedUsers.everyone }}</option> + <option value="followers">{{ i18n.ts._chat._chatAllowedUsers.followers }}</option> + <option value="following">{{ i18n.ts._chat._chatAllowedUsers.following }}</option> + <option value="mutual">{{ i18n.ts._chat._chatAllowedUsers.mutual }}</option> + <option value="none">{{ i18n.ts._chat._chatAllowedUsers.none }}</option> + <template #caption>{{ i18n.ts._chat.chatAllowedUsers_note }}</template> + </MkSelect> + </SearchMarker> + </FormSection> + <SearchMarker :keywords="['lockdown']"> <FormSection> <template #label><SearchLabel>{{ i18n.ts.lockdown }}</SearchLabel><span class="_beta">{{ i18n.ts.beta }}</span></template> @@ -208,6 +222,7 @@ const hideOnlineStatus = ref($i.hideOnlineStatus); const publicReactions = ref($i.publicReactions); const followingVisibility = ref($i.followingVisibility); const followersVisibility = ref($i.followersVisibility); +const chatScope = ref($i.chatScope); const makeNotesFollowersOnlyBefore_type = computed(() => { if (makeNotesFollowersOnlyBefore.value == null) { @@ -260,6 +275,7 @@ function save() { publicReactions: !!publicReactions.value, followingVisibility: followingVisibility.value, followersVisibility: followersVisibility.value, + chatScope: chatScope.value, }); } diff --git a/packages/frontend/src/pages/settings/sounds.vue b/packages/frontend/src/pages/settings/sounds.vue index 9e5c82a266..4461ee1ab1 100644 --- a/packages/frontend/src/pages/settings/sounds.vue +++ b/packages/frontend/src/pages/settings/sounds.vue @@ -85,6 +85,7 @@ const sounds = ref<Record<OperationType, Ref<SoundStore>>>({ noteMy: prefer.r['sound.on.noteMy'], notification: prefer.r['sound.on.notification'], reaction: prefer.r['sound.on.reaction'], + chatMessage: prefer.r['sound.on.chatMessage'], }); function getSoundTypeName(f: SoundType): string { diff --git a/packages/frontend/src/preferences/def.ts b/packages/frontend/src/preferences/def.ts index c3c37553d7..127ebeef0c 100644 --- a/packages/frontend/src/preferences/def.ts +++ b/packages/frontend/src/preferences/def.ts @@ -136,6 +136,7 @@ export const PREF_DEF = { 'clips', 'drive', 'followRequests', + 'chat', '-', 'explore', 'announcements', @@ -331,6 +332,7 @@ export const PREF_DEF = { plugins: { default: [] as Plugin[], }, + 'sound.masterVolume': { default: 0.3, }, @@ -352,6 +354,10 @@ export const PREF_DEF = { 'sound.on.reaction': { default: { type: 'syuilo/bubble2', volume: 1 } as SoundStore, }, + 'sound.on.chatMessage': { + default: { type: 'syuilo/waon', volume: 1 } as SoundStore, + }, + 'deck.alwaysShowMainColumn': { default: true, }, @@ -364,6 +370,7 @@ export const PREF_DEF = { 'deck.columnAlign': { default: 'left' as 'left' | 'right' | 'center', }, + 'game.dropAndFusion': { default: { bgmVolume: 0.25, diff --git a/packages/frontend/src/router.definition.ts b/packages/frontend/src/router.definition.ts index 3b60ee68e3..0585a31fd1 100644 --- a/packages/frontend/src/router.definition.ts +++ b/packages/frontend/src/router.definition.ts @@ -41,6 +41,22 @@ export const ROUTE_DEF = [{ path: '/clips/:clipId', component: page(() => import('@/pages/clip.vue')), }, { + path: '/chat', + component: page(() => import('@/pages/chat/home.vue')), + loginRequired: true, +}, { + path: '/chat/user/:userId', + component: page(() => import('@/pages/chat/room.vue')), + loginRequired: true, +}, { + path: '/chat/room/:roomId', + component: page(() => import('@/pages/chat/room.vue')), + loginRequired: true, +}, { + path: '/chat/messages/:messageId', + component: page(() => import('@/pages/chat/message.vue')), + loginRequired: true, +}, { path: '/instance-info/:host', component: page(() => import('@/pages/instance-info.vue')), }, { diff --git a/packages/frontend/src/style.scss b/packages/frontend/src/style.scss index 8c5617b72e..f122f47c06 100644 --- a/packages/frontend/src/style.scss +++ b/packages/frontend/src/style.scss @@ -113,10 +113,6 @@ a { outline-offset: 2px; } - &:hover { - text-decoration: underline; - } - &[target="_blank"] { -webkit-touch-callout: default; } @@ -335,13 +331,13 @@ rt { ._gaps_m { display: flex; flex-direction: column; - gap: 1.5em; + gap: 21px; } ._gaps_s { display: flex; flex-direction: column; - gap: 0.75em; + gap: 10px; } ._gaps { diff --git a/packages/frontend/src/types/menu.ts b/packages/frontend/src/types/menu.ts index 5d1fc1fe72..820759ce61 100644 --- a/packages/frontend/src/types/menu.ts +++ b/packages/frontend/src/types/menu.ts @@ -15,16 +15,16 @@ export type MenuAction = (ev: MouseEvent) => void; export type MenuDivider = { type: 'divider' }; export type MenuNull = undefined; -export type MenuLabel = { type: 'label', text: string }; -export type MenuLink = { type: 'link', to: string, text: string, icon?: string, indicate?: boolean, avatar?: Misskey.entities.User }; -export type MenuA = { type: 'a', href: string, target?: string, download?: string, text: string, icon?: string, indicate?: boolean }; +export type MenuLabel = { type: 'label', text: string, caption?: string }; +export type MenuLink = { type: 'link', to: string, text: string, caption?: string, icon?: string, indicate?: boolean, avatar?: Misskey.entities.User }; +export type MenuA = { type: 'a', href: string, target?: string, download?: string, text: string, caption?: string, icon?: string, indicate?: boolean }; export type MenuUser = { type: 'user', user: Misskey.entities.User, active?: boolean, indicate?: boolean, action: MenuAction }; -export type MenuSwitch = { type: 'switch', ref: Ref<boolean>, text: string, icon?: string, disabled?: boolean | Ref<boolean> }; -export type MenuButton = { type?: 'button', text: string, icon?: string, indicate?: boolean, danger?: boolean, active?: boolean | ComputedRef<boolean>, avatar?: Misskey.entities.User; action: MenuAction }; -export type MenuRadio = { type: 'radio', text: string, icon?: string, ref: Ref<MenuRadioOptionsDef[keyof MenuRadioOptionsDef]>, options: MenuRadioOptionsDef, disabled?: boolean | Ref<boolean> }; -export type MenuRadioOption = { type: 'radioOption', text: string, action: MenuAction; active?: boolean | ComputedRef<boolean> }; +export type MenuSwitch = { type: 'switch', ref: Ref<boolean>, text: string, caption?: string, icon?: string, disabled?: boolean | Ref<boolean> }; +export type MenuButton = { type?: 'button', text: string, caption?: string, icon?: string, indicate?: boolean, danger?: boolean, active?: boolean | ComputedRef<boolean>, avatar?: Misskey.entities.User; action: MenuAction }; +export type MenuRadio = { type: 'radio', text: string, caption?: string, icon?: string, ref: Ref<MenuRadioOptionsDef[keyof MenuRadioOptionsDef]>, options: MenuRadioOptionsDef, disabled?: boolean | Ref<boolean> }; +export type MenuRadioOption = { type: 'radioOption', text: string, caption?: string, action: MenuAction; active?: boolean | ComputedRef<boolean> }; export type MenuComponent<T extends Component = any> = { type: 'component', component: T, props?: ComponentProps<T> }; -export type MenuParent = { type: 'parent', text: string, icon?: string, children: MenuItem[] | (() => Promise<MenuItem[]> | MenuItem[]) }; +export type MenuParent = { type: 'parent', text: string, caption?: string, icon?: string, children: MenuItem[] | (() => Promise<MenuItem[]> | MenuItem[]) }; export type MenuPending = { type: 'pending' }; diff --git a/packages/frontend/src/ui/_common_/navbar.vue b/packages/frontend/src/ui/_common_/navbar.vue index 98d6f329ab..88ce3a96e2 100644 --- a/packages/frontend/src/ui/_common_/navbar.vue +++ b/packages/frontend/src/ui/_common_/navbar.vue @@ -65,7 +65,7 @@ SPDX-License-Identifier: AGPL-3.0-only </svg> --> - <div :class="$style.subButtons"> + <div v-if="!forceIconOnly" :class="$style.subButtons"> <div :class="[$style.subButton, $style.menuEditButton]"> <svg viewBox="0 0 16 64" :class="$style.subButtonShape"> <g transform="matrix(0.333333,0,0,0.222222,0.000895785,21.3333)"> @@ -74,9 +74,9 @@ SPDX-License-Identifier: AGPL-3.0-only </svg> <button class="_button" :class="$style.subButtonClickable" @click="menuEdit"><i :class="$style.subButtonIcon" class="ti ti-settings-2"></i></button> </div> - <div v-if="!forceIconOnly" :class="$style.subButtonGapFill"></div> - <div v-if="!forceIconOnly" :class="$style.subButtonGapFillDivider"></div> - <div v-if="!forceIconOnly" :class="[$style.subButton, $style.toggleButton]"> + <div :class="$style.subButtonGapFill"></div> + <div :class="$style.subButtonGapFillDivider"></div> + <div :class="[$style.subButton, $style.toggleButton]"> <svg viewBox="0 0 16 64" :class="$style.subButtonShape"> <g transform="matrix(0.333333,0,0,0.222222,0.000895785,21.3333)"> <path d="M47.488,7.995C47.79,10.11 47.943,12.266 47.943,14.429C47.997,26.989 47.997,84 47.997,84C47.997,84 44.018,118.246 23.997,133.5C-0.374,152.07 -0.003,192 -0.003,192L-0.003,-96C-0.003,-96 0.151,-56.216 23.997,-37.5C40.861,-24.265 46.043,-1.243 47.488,7.995Z" style="fill:var(--MI_THEME-navBg);"/> diff --git a/packages/frontend/src/utility/autogen/settings-search-index.ts b/packages/frontend/src/utility/autogen/settings-search-index.ts index fd92876880..64fe328478 100644 --- a/packages/frontend/src/utility/autogen/settings-search-index.ts +++ b/packages/frontend/src/utility/autogen/settings-search-index.ts @@ -240,20 +240,25 @@ export const searchIndexes: SearchIndexItem[] = [ keywords: ['explore', i18n.ts.makeExplorableDescription], }, { - id: '7vr04wKol', + id: 'xEYlOghao', + label: i18n.ts._chat.chatAllowedUsers, + keywords: ['chat'], + }, + { + id: 'BnOtlyaAh', children: [ { - id: 'Av7fAaHv8', + id: 'BzMIVBpL0', label: i18n.ts._accountSettings.requireSigninToViewContents, keywords: ['login', 'signin'], }, { - id: '5RbESWefG', + id: 'jJUqPqBAv', label: i18n.ts._accountSettings.makeNotesFollowersOnlyBefore, keywords: ['follower', i18n.ts._accountSettings.makeNotesFollowersOnlyBeforeDescription], }, { - id: 'hdzwDs3qd', + id: 'ra10txIFV', label: i18n.ts._accountSettings.makeNotesHiddenBefore, keywords: ['hidden', i18n.ts._accountSettings.makeNotesHiddenBeforeDescription], }, diff --git a/packages/frontend/src/utility/get-user-menu.ts b/packages/frontend/src/utility/get-user-menu.ts index de20f2678e..37c88c9665 100644 --- a/packages/frontend/src/utility/get-user-menu.ts +++ b/packages/frontend/src/utility/get-user-menu.ts @@ -151,24 +151,6 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router const menuItems: MenuItem[] = []; - menuItems.push({ - icon: 'ti ti-at', - text: i18n.ts.copyUsername, - action: () => { - copyToClipboard(`@${user.username}@${user.host ?? host}`); - }, - }); - - if (notesSearchAvailable && (user.host == null || canSearchNonLocalNotes)) { - menuItems.push({ - icon: 'ti ti-search', - text: i18n.ts.searchThisUsersNotes, - action: () => { - router.push(`/search?username=${encodeURIComponent(user.username)}${user.host != null ? '&host=' + encodeURIComponent(user.host) : ''}`); - }, - }); - } - if (iAmModerator) { menuItems.push({ icon: 'ti ti-user-exclamation', @@ -176,10 +158,27 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router action: () => { router.push(`/admin/user/${user.id}`); }, - }); + }, { type: 'divider' }); } menuItems.push({ + icon: 'ti ti-at', + text: i18n.ts.copyUsername, + action: () => { + copyToClipboard(`@${user.username}@${user.host ?? host}`); + }, + }); + + menuItems.push({ + icon: 'ti ti-share', + text: i18n.ts.copyProfileUrl, + action: () => { + const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${toUnicode(user.host)}`; + copyToClipboard(`${url}/${canonical}`); + }, + }); + + menuItems.push({ icon: 'ti ti-rss', text: i18n.ts.copyRSS, action: () => { @@ -210,24 +209,18 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router }); } - menuItems.push({ - icon: 'ti ti-share', - text: i18n.ts.copyProfileUrl, - action: () => { - const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${toUnicode(user.host)}`; - copyToClipboard(`${url}/${canonical}`); - }, - }); - - if ($i) { + if (notesSearchAvailable && (user.host == null || canSearchNonLocalNotes)) { menuItems.push({ - icon: 'ti ti-mail', - text: i18n.ts.sendMessage, + icon: 'ti ti-search', + text: i18n.ts.searchThisUsersNotes, action: () => { - const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${user.host}`; - os.post({ specified: user, initialText: `${canonical} ` }); + router.push(`/search?username=${encodeURIComponent(user.username)}${user.host != null ? '&host=' + encodeURIComponent(user.host) : ''}`); }, - }, { type: 'divider' }, { + }); + } + + if ($i) { + menuItems.push({ type: 'divider' }, { icon: 'ti ti-pencil', text: i18n.ts.editMemo, action: editMemo, @@ -363,6 +356,18 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router //} menuItems.push({ type: 'divider' }, { + icon: 'ti ti-mail', + text: i18n.ts.sendMessage, + action: () => { + const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${user.host}`; + os.post({ specified: user, initialText: `${canonical} ` }); + }, + }, { + type: 'link', + icon: 'ti ti-messages', + text: i18n.ts._chat.chatWithThisUser, + to: `/chat/user/${user.id}`, + }, { type: 'divider' }, { icon: user.isMuted ? 'ti ti-eye' : 'ti ti-eye-off', text: user.isMuted ? i18n.ts.unmute : i18n.ts.mute, action: toggleMute, diff --git a/packages/frontend/src/utility/sound.ts b/packages/frontend/src/utility/sound.ts index 796af0e5ca..f217bdfcd5 100644 --- a/packages/frontend/src/utility/sound.ts +++ b/packages/frontend/src/utility/sound.ts @@ -77,6 +77,7 @@ export const operationTypes = [ 'note', 'notification', 'reaction', + 'chatMessage', ] as const; /** サウンドの種類 */ diff --git a/packages/frontend/src/utility/upload.ts b/packages/frontend/src/utility/upload.ts index eb3cbd3dfa..e13d793ffb 100644 --- a/packages/frontend/src/utility/upload.ts +++ b/packages/frontend/src/utility/upload.ts @@ -32,7 +32,7 @@ const mimeTypeMap = { export function uploadFile( file: File, - folder?: string | Misskey.entities.DriveFolder, + folder?: string | Misskey.entities.DriveFolder | null, name?: string, keepOriginal: boolean = prefer.s.keepOriginalUploading, ): Promise<Misskey.entities.DriveFile> { |