summaryrefslogtreecommitdiff
path: root/packages/frontend/src
diff options
context:
space:
mode:
authorsyuilo <4439005+syuilo@users.noreply.github.com>2025-03-24 21:32:46 +0900
committerGitHub <noreply@github.com>2025-03-24 21:32:46 +0900
commitf1f24e39d2df3135493e2c2087230b428e2d02b7 (patch)
treea5ae0e9d2cf810649b2f4e08ef4d00ce7ea91dc9 /packages/frontend/src
parentfix(frontend): fix broken styles (diff)
downloadsharkey-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')
-rw-r--r--packages/frontend/src/boot/main-boot.ts25
-rw-r--r--packages/frontend/src/components/MkButton.vue10
-rw-r--r--packages/frontend/src/components/MkDateSeparatedList.vue22
-rw-r--r--packages/frontend/src/components/MkFukidashi.vue9
-rw-r--r--packages/frontend/src/components/MkMediaList.vue1
-rw-r--r--packages/frontend/src/components/MkMenu.vue66
-rw-r--r--packages/frontend/src/components/MkPagination.vue39
-rw-r--r--packages/frontend/src/components/MkPolkadots.vue40
-rw-r--r--packages/frontend/src/components/MkUrlPreview.vue1
-rw-r--r--packages/frontend/src/local-storage.ts2
-rw-r--r--packages/frontend/src/navbar.ts8
-rw-r--r--packages/frontend/src/pages/admin/roles.editor.vue20
-rw-r--r--packages/frontend/src/pages/admin/roles.vue8
-rw-r--r--packages/frontend/src/pages/chat/XMessage.vue245
-rw-r--r--packages/frontend/src/pages/chat/XRoom.vue41
-rw-r--r--packages/frontend/src/pages/chat/home.home.vue252
-rw-r--r--packages/frontend/src/pages/chat/home.invitations.vue98
-rw-r--r--packages/frontend/src/pages/chat/home.joiningRooms.vue54
-rw-r--r--packages/frontend/src/pages/chat/home.ownedRooms.vue54
-rw-r--r--packages/frontend/src/pages/chat/home.vue60
-rw-r--r--packages/frontend/src/pages/chat/message.vue55
-rw-r--r--packages/frontend/src/pages/chat/room.form.vue333
-rw-r--r--packages/frontend/src/pages/chat/room.info.vue87
-rw-r--r--packages/frontend/src/pages/chat/room.members.vue73
-rw-r--r--packages/frontend/src/pages/chat/room.search.vue68
-rw-r--r--packages/frontend/src/pages/chat/room.vue426
-rw-r--r--packages/frontend/src/pages/settings/notifications.vue5
-rw-r--r--packages/frontend/src/pages/settings/privacy.vue16
-rw-r--r--packages/frontend/src/pages/settings/sounds.vue1
-rw-r--r--packages/frontend/src/preferences/def.ts7
-rw-r--r--packages/frontend/src/router.definition.ts16
-rw-r--r--packages/frontend/src/style.scss8
-rw-r--r--packages/frontend/src/types/menu.ts16
-rw-r--r--packages/frontend/src/ui/_common_/navbar.vue8
-rw-r--r--packages/frontend/src/utility/autogen/settings-search-index.ts13
-rw-r--r--packages/frontend/src/utility/get-user-menu.ts73
-rw-r--r--packages/frontend/src/utility/sound.ts1
-rw-r--r--packages/frontend/src/utility/upload.ts2
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> {