Feat: メッセージの送受信 / New: ユーザーのiconプロパティ / New: logエンティティ・リポジトリ / Chg: コミュニティリポジトリのスキーマのiconをoptionalに / Del: 不要なimport / Fix: Vue起動前のindex.htmlの背景色をVueと同期 / Enhance: Service Workerを改善 / Fix: 最初に開いたページが動作しない問題 / Feat: 上部の通知モーダル / Feat: 閉じることができないエラーのモーダルに再読み込みボタンを追加 / Fix: はみ出す挙動などのCSSを修正

This commit is contained in:
2026-05-31 13:54:55 +09:00
parent beb0e25ad9
commit cbf18aec8f
37 changed files with 1174 additions and 83 deletions
@@ -9,14 +9,114 @@
v-if="!isProcessing && channel"
class="channel"
>
<div class="messages" @scroll.passive="messagesScroll">
<div class="none-message" v-if="messages.length === 0">
<span>何ということでしょう</span>
<span>メッセージがありませんね</span>
</div>
<Progress
v-if="isLoading"
:size="10"
class="loading-progress"
/>
<Message
v-for="message of messages"
@deleteMessage="deleteMessage"
:key="message.id"
:message="message"
/>
</div>
<form>
<textarea
@input="resize"
@keydown.ctrl.enter="send"
class="message-input"
rows="1"
v-model="message"
:disabled="isSending"
:placeholder="`${channel.name}に送信`"
/>
<Icon
@click="send"
:icon='isSending
? "line-md:loading-twotone-loop"
: "material-symbols:send-rounded"'
/>
</form>
</div>
</template>
<style scoped>
.processing-progress {
margin: auto;
}
.channel {
position: relative;
display: flex;
flex-direction: column-reverse;
flex-direction: column;
width: 100%;
height: 100%;
}
.messages {
display: flex;
flex-direction: column;
height: 100%;
box-sizing: border-box;
overflow: scroll;
}
.none-message {
margin: auto;
display: flex;
flex-direction: column;
text-align: center;
gap: 0.5rem;
}
.none-message span:first-child {
font-size: 1.25rem;
font-weight: bold;
}
.loading-progress {
margin: 0 auto;
}
.channel form {
display: flex;
gap: 0.5rem;
padding: 1rem;
margin-bottom: 1rem;
border-radius: 1rem;
background-color: var(--bg-sub-color);
}
.message-input {
width: 100%;
max-height: 40dvh;
resize: none;
border: none;
outline: none;
color: var(--text-color);
background-color: var(--bg-sub-color);
}
.message-input:disabled {
color: oklch(from var(--text-color) calc(l - 0.2) c h);
cursor: not-allowed;
}
.channel form svg {
cursor: pointer;
flex-shrink: 0;
margin-top: auto;
width: 2rem;
height: 2rem;
}
</style>
@@ -24,18 +124,18 @@
import { useRoute, useRouter } from "vue-router";
import { account, presentCommunity, serverInfo } from "@/lib/account";
import client from "@/lib/client";
import { createModal } from "@/lib/modal";
import { createModal, createTopNotice } from "@/lib/modal";
import GoHomeError from "@/components/Modal/GoHomeError.vue";
import { ref } from "vue";
import Progress from "@/components/Progress.vue";
import { title } from "@/lib/router";
import type ApiMap from "lynqchat-js/1.0.0-alpha.0/map";
import { Icon } from "@iconify/vue";
import ErrorModal from "@/components/Modal/Error.vue";
import Message from "@/components/Message.vue";
const route = useRoute();
const router = useRouter();
const isProcessing = ref<boolean>(false);
const channel = ref<Extract<ApiMap["channel/list"]["response"], { channels: any }>["channels"][number]>();
if (!serverInfo.value.success) {
throw new Error("サーバー情報の取得に失敗しました。");
@@ -55,6 +155,176 @@ if (!account.value.success) {
}
}
const isProcessing = ref<boolean>(false);
const isSending = ref<boolean>(false);
const channel = ref<Extract<ApiMap["channel/list"]["response"], { channels: any }>["channels"][number]>();
const messages = ref<Extract<ApiMap["message/list"]["response"], { messages: any }>["messages"]>([]);
const message = ref<string>("");
const isInsideRange = ref(false);
const isLoading = ref(false);
const messagesScroll = async (event: any) => {
const handleSize = 200;
const isCurrentInside = event.target.scrollTop < handleSize;
if (isCurrentInside && !isInsideRange.value && !isLoading.value) {
isLoading.value = true;
await new Promise<void>(resolve => setTimeout(resolve, 0));
document.querySelector(".messages")!.scrollTop = 0;
if (channel.value === undefined) {
isLoading.value = false;
createModal({
component: ErrorModal,
onClose: () => isSending.value = false,
props: {
error: "チャンネルの情報がありません。",
canClose: true,
},
});
return;
}
const messagesRes = await client.value.request("message/list", {
channel: channel.value.id,
until: messages.value[0]?.id,
});
if (!messagesRes.success) {
isLoading.value = false;
createModal({
component: ErrorModal,
onClose: async () => await router.push("/"),
props: {
error: "メッセージが取得できませんでした。",
canClose: true,
},
});
return;
}
messages.value = messagesRes.messages.toReversed().concat(messages.value);
isLoading.value = false;
}
isInsideRange.value = isCurrentInside;
}
const resize = (event: any) => {
event.target.style.height = "auto";
event.target.style.height = event.target.scrollHeight + 'px';
}
const send = async (e: Event) => {
e.preventDefault();
isSending.value = true;
if (channel.value === undefined) {
createModal({
component: ErrorModal,
onClose: () => isSending.value = false,
props: {
error: "チャンネルの情報がありません。",
canClose: true,
},
});
return;
}
if (message.value.trim().length > 4096) {
isSending.value = false;
createTopNotice({
props: {
icon: "material-symbols:warning-rounded",
iconColor: "var(--warn-color)",
error: "メッセージは4096文字までである必要があります。",
},
});
return;
}
if (message.value.trim().length === 0) {
isSending.value = false;
createTopNotice({
props: {
icon: "material-symbols:warning-rounded",
iconColor: "var(--warn-color)",
message: "メッセージを空にすることは出来ません。",
},
});
return;
}
const channelId = route.params.channelId;
if (typeof channelId !== "string") {
isSending.value = false;
createModal({
component: ErrorModal,
props: {
error: "チャンネルが取得できませんでした。",
canClose: true,
},
});
return;
}
if (!account.value.success) {
isSending.value = false;
createModal({
component: ErrorModal,
props: {
error: "アカウント情報の取得に失敗しました。",
canClose: true,
},
});
return;
}
const sendRes = await client.value.request("message/send", {
message: message.value,
channel: channel.value.id,
});
if (!sendRes.success) {
isSending.value = false;
createModal({
component: ErrorModal,
props: {
error: "メッセージの送信に失敗しました。",
canClose: true,
},
});
return;
}
messages.value = [...messages.value, {
id: sendRes.id,
message: message.value,
channel: channelId,
createdAt: new Date().toUTCString(),
createdBy: {
...account.value,
},
}];
message.value = "";
const inputElem = document.querySelector(".message-input") as HTMLTextAreaElement;
inputElem.style.height = "auto";
isSending.value = false;
await new Promise<void>(resolve => setTimeout(resolve, 0));
const messagesElem = document.querySelector(".messages")!;
messagesElem.scrollTop = messagesElem.scrollHeight;
}
const deleteMessage = (data: { id: string }) => {
const index = messages.value.findIndex(msg => msg.id === data.id);
if (index !== -1)
messages.value = messages.value.toSpliced(index, 1);
}
(async () => {
isProcessing.value = true;
@@ -108,6 +378,27 @@ if (!account.value.success) {
document.title = `${channelRes.channel.name} - ${presentCommunity.value.name} | ${serverInfo.value.name}`;
title.value = `${channelRes.channel.name} - ${presentCommunity.value.name}`;
const messagesRes = await client.value.request("message/list", {
channel: channel.value.id,
});
if (!messagesRes.success) {
isProcessing.value = false;
createModal({
component: GoHomeError,
onClose: async () => await router.push("/"),
props: {
error: "メッセージが取得できませんでした。",
},
});
return;
}
messages.value = messages.value.concat(messagesRes.messages).reverse();
isProcessing.value = false;
await new Promise<void>(resolve => setTimeout(resolve, 0));
const messagesElem = document.querySelector(".messages")!;
messagesElem.scrollTop = messagesElem.scrollHeight;
})();
</script>