Feat: Chat (#15686)
* wip
* wip
* wip
* wip
* wip
* wip
* Update types.ts
* Create 1742203321812-chat.js
* wip
* wip
* Update room.vue
* Update home.vue
* Update home.vue
* Update ja-JP.yml
* Update index.d.ts
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* Update CHANGELOG.md
* wip
* Update home.vue
* clean up
* Update misskey-js.api.md
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* lint fixes
* lint
* Update UserEntityService.ts
* search
* wip
* 🎨
* wip
* Update home.ownedRooms.vue
* wip
* Update CHANGELOG.md
* Update style.scss
* wip
* improve performance
* improve performance
* Update timeline.test.ts
This commit is contained in:
parent
0471e457fe
commit
f1f24e39d2
129 changed files with 8176 additions and 773 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
245
packages/frontend/src/pages/chat/XMessage.vue
Normal file
245
packages/frontend/src/pages/chat/XMessage.vue
Normal file
|
|
@ -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>
|
||||
41
packages/frontend/src/pages/chat/XRoom.vue
Normal file
41
packages/frontend/src/pages/chat/XRoom.vue
Normal file
|
|
@ -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>
|
||||
252
packages/frontend/src/pages/chat/home.home.vue
Normal file
252
packages/frontend/src/pages/chat/home.home.vue
Normal file
|
|
@ -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>
|
||||
98
packages/frontend/src/pages/chat/home.invitations.vue
Normal file
98
packages/frontend/src/pages/chat/home.invitations.vue
Normal file
|
|
@ -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>
|
||||
54
packages/frontend/src/pages/chat/home.joiningRooms.vue
Normal file
54
packages/frontend/src/pages/chat/home.joiningRooms.vue
Normal file
|
|
@ -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>
|
||||
54
packages/frontend/src/pages/chat/home.ownedRooms.vue
Normal file
54
packages/frontend/src/pages/chat/home.ownedRooms.vue
Normal file
|
|
@ -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>
|
||||
60
packages/frontend/src/pages/chat/home.vue
Normal file
60
packages/frontend/src/pages/chat/home.vue
Normal file
|
|
@ -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>
|
||||
55
packages/frontend/src/pages/chat/message.vue
Normal file
55
packages/frontend/src/pages/chat/message.vue
Normal file
|
|
@ -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>
|
||||
333
packages/frontend/src/pages/chat/room.form.vue
Normal file
333
packages/frontend/src/pages/chat/room.form.vue
Normal file
|
|
@ -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>
|
||||
87
packages/frontend/src/pages/chat/room.info.vue
Normal file
87
packages/frontend/src/pages/chat/room.info.vue
Normal file
|
|
@ -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>
|
||||
73
packages/frontend/src/pages/chat/room.members.vue
Normal file
73
packages/frontend/src/pages/chat/room.members.vue
Normal file
|
|
@ -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>
|
||||
68
packages/frontend/src/pages/chat/room.search.vue
Normal file
68
packages/frontend/src/pages/chat/room.search.vue
Normal file
|
|
@ -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>
|
||||
426
packages/frontend/src/pages/chat/room.vue
Normal file
426
packages/frontend/src/pages/chat/room.vue
Normal file
|
|
@ -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>
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue