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
This commit is contained in:
syuilo 2025-03-24 21:32:46 +09:00 committed by GitHub
parent 0471e457fe
commit f1f24e39d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
129 changed files with 8176 additions and 773 deletions

View file

@ -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;
}

View file

@ -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);
}
> .list-move {
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
}
&.deny-move-transition > .list-move {
transition: none !important;
}
> .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) {

View file

@ -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 {

View file

@ -227,7 +227,6 @@ defineExpose({
.container {
position: relative;
width: 100%;
margin-top: 4px;
}
.medias {

View file

@ -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;

View file

@ -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(() => {

View file

@ -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>

View file

@ -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;