sinProject-Inc/talk

View on GitHub
src/routes/(authed)/chat/[[room_id]]/+page.svelte

Summary

Maintainability
Test Coverage
<script lang="ts">
    import { goto } from '$app/navigation'
    import { base } from '$app/paths'
    import { App } from '$lib/app/app'
    import { AvatarUrl } from '$lib/avatar/avatar_url'
    import type { ChatMemberEntity, MessageSet } from '$lib/chat/chat'
    import IconButton from '$lib/components/icon_button.svelte'
    import AddIcon from '$lib/components/icons/add_icon.svelte'
    import DesktopWindowsIcon from '$lib/components/icons/desktop_windows_icon.svelte'
    import FillIcon from '$lib/components/icons/fill_icon.svelte'
    import LoadingIcon from '$lib/components/icons/loading_icon.svelte'
    import NotificationsActiveIcon from '$lib/components/icons/notifications_active_icon.svelte'
    import NotificationsIcon from '$lib/components/icons/notifications_icon.svelte'
    import PersonIcon from '$lib/components/icons/person_icon.svelte'
    import PhoneAndroidIcon from '$lib/components/icons/phone_android_icon.svelte'
    import SignInIcon from '$lib/components/icons/sign_in_icon.svelte'
    import SignOutIcon from '$lib/components/icons/sign_out_icon.svelte'
    import StopIcon from '$lib/components/icons/stop_icon.svelte'
    import VoiceIcon from '$lib/components/icons/voice_icon.svelte'
    import Navbar from '$lib/components/navbar.svelte'
    import SocialMetaTags from '$lib/components/social_meta_tags.svelte'
    import VersionFooter from '$lib/components/version_footer.svelte'
    import { AppLocalStorage } from '$lib/locale/app_local_storage'
    import { LocaleCode } from '$lib/locale/locale_code'
    import { SpeechDivElement } from '$lib/speech/speech_div_element'
    import { SpeechText } from '$lib/speech/speech_text'
    import { SubmissionText } from '$lib/speech/submission_text'
    import { WebSpeechRecognition } from '$lib/speech/web_speech_recognition'
    import { GetTranslationApi } from '$lib/translation/get_translation_api'
    import { UserId } from '$lib/user/user_id'
    import { Direction } from '$lib/view/direction'
    import { EventKey } from '$lib/view/event_key'
    import { LocaleSelectElement } from '$lib/view/locale_select_element'
    import { WebLogger } from '$lib/view/log/web_logger'
    import { Urlify } from '$lib/view/urlify'
    import { Web } from '$lib/view/web'
    import type { ChatLog, Locale } from '@prisma/client'
    import { io } from 'socket.io-client'
    import { onDestroy, onMount } from 'svelte'
    import { _ } from 'svelte-i18n'
    import { fly, slide } from 'svelte/transition'
    import { v4 as uuidv4 } from 'uuid'
    import type { PageData } from './$types'

    type ChatLogItem = {
        data: ChatLog
        translated: string
    }

    export let data: PageData

    let locale_select_element: HTMLSelectElement
    let chat_log_div_element: HTMLDivElement

    let name_element: HTMLInputElement
    let message_div_element: HTMLDivElement

    let locales: Locale[] = []
    let name = ''
    let message = ''
    let chat_log_items: ChatLogItem[] = []

    let web_speech_recognition: WebSpeechRecognition | undefined
    let listening = false

    let is_visible = true
    let is_notification_enabled = false

    let joined = false
    let chat_member_entities: ChatMemberEntity[] = []

    let sending = false

    let observer: ResizeObserver
    let saved_chat_log_height = 0

    const web_logger = new WebLogger('chat')

    const user_id = new UserId(data.user_id)

    $: can_send = !!name && !!message

    const socket = io({
        transports: ['websocket'],
    })

    async function show_log_translation(chat_log_item: ChatLogItem): Promise<void> {
        const speech_text = new SpeechText(chat_log_item.data.message)
        const source_locale_code = new LocaleCode(chat_log_item.data.locale_code)
        const target_locale_code = new LocaleCode(locale_select_element.value)

        const translated_texts = await new GetTranslationApi(
            speech_text,
            source_locale_code,
            target_locale_code
        ).fetch()

        chat_log_item.translated = translated_texts[0]?.text ?? ''
    }

    async function show_translation(): Promise<void> {
        const locale_code = locale_select_element.value
        const items_for_translating = chat_log_items.filter((chat_log_item) => {
            if (chat_log_item.translated) return false
            if (chat_log_item.data.locale_code === locale_code) return false

            return true
        })

        const promises: Promise<void>[] = []

        items_for_translating.forEach((chat_log_item) => {
            promises.push(show_log_translation(chat_log_item))
        })

        await Promise.all(promises)

        chat_log_items = chat_log_items
    }

    function show_notification(notification_message: string): void {
        if (!is_notification_enabled) return
        if (is_visible) return

        navigator.serviceWorker.ready.then((registration: ServiceWorkerRegistration) => {
            // console.info('notification_message', notification_message)

            registration.showNotification(`${App.company_and_app_name} - Chat`, {
                body: notification_message,
                icon: '/icon-192.avif',
            })

            new Audio('./sound/notification-140376.mp3').play()
        })
    }

    function show_message_notification(translated_chat_log: {
        data: ChatLog
        translated: string
    }): void {
        const notification_message = translated_chat_log.translated || translated_chat_log.data.message

        show_notification(notification_message)
    }

    function scroll_to_bottom(): void {
        setTimeout(() => {
            chat_log_div_element.scrollTop = chat_log_div_element.scrollHeight
        }, 10)
    }

    async function send(): Promise<void> {
        if (sending) return

        name = name.trim()
        message = message.trim()
        message = message.replace(/(\r\n){3,}|\r{3,}|\n{3,}/g, '\n\n')

        if (!name) {
            name_element.focus()

            return
        }

        if (!message) {
            message_div_element.focus()

            return
        }

        const submission_text = new SubmissionText(message)

        const message_set: MessageSet = {
            locale_code: locale_select_element.value,
            name,
            sender_id: user_id.id,
            message: submission_text.text,
        }

        // console.info(`socket.io send: ${message}`)

        // socket.emitの後にsendingをtrueにすると、callbackが呼ばれる後にsendingがtrueになることもある
        sending = true
        socket.emit('message', message_set)
    }

    async function on_click_send(): Promise<void> {
        web_logger.info(`on_click_send: ${message}, name: ${name}`)
        await send()
    }

    function on_keydown_name(event: KeyboardEvent): void {
        const event_key = new EventKey(event)

        if (!event_key.is_enter) return

        event.preventDefault()

        web_logger.info(`on_enter_name: ${name}`)
        join()
    }

    async function on_keydown_message(event: KeyboardEvent): Promise<void> {
        const event_key = new EventKey(event)

        if (event_key.should_submit) {
            event.preventDefault()
            web_logger.info(`on_enter_message: ${message}, name: ${name}`)
            await send()
        }
    }

    async function set_app_locale(): Promise<void> {
        // DO NOTHING
        // const locale_code = new LocaleCode(locale_select_element.value)
        // $locale = locale_code.code
        // await waitLocale($locale)
    }

    function init_name(): void {
        name = AppLocalStorage.instance.name
    }

    async function init_locale(): Promise<void> {
        locales = JSON.parse(data.locales) as Locale[]

        new LocaleSelectElement(locale_select_element, locales).append_options_long()

        locale_select_element.value = AppLocalStorage.instance.to_locale
        await set_app_locale()
    }

    function init_focus(): void {
        name_element.focus()
    }

    async function on_change_locale_select(): Promise<void> {
        web_logger.info(`on_change_locale_select: ${locale_select_element.value}, name: ${name}`)

        AppLocalStorage.instance.to_locale = locale_select_element.value
        await set_app_locale()

        chat_log_items.forEach((chat_log_item) => (chat_log_item.translated = ''))

        await show_translation()
    }

    function on_change_name(): void {
        if (!name) return

        AppLocalStorage.instance.name = name
    }

    function to_local_time(created_at?: Date): string {
        if (!created_at) return ''

        const date = new Date(created_at)

        return date.toLocaleString([], { hour12: false, hour: '2-digit', minute: '2-digit' })
    }

    function move_caret_to_end(): void {
        const range = document.createRange()
        const selection = window.getSelection()

        range.selectNodeContents(message_div_element)
        range.collapse(false)

        selection?.removeAllRanges()
        selection?.addRange(range)
    }

    function on_end_listening(): void {
        listening = false
        message = message_div_element.textContent || ''

        move_caret_to_end()
    }

    function start_listening(): void {
        const locale_code = new LocaleCode(locale_select_element.value)
        const speech_text_element = new SpeechDivElement(message_div_element)

        web_logger.info(`start_listening: locale: ${locale_code.code}, name: ${name}`)

        web_speech_recognition = new WebSpeechRecognition(
            locale_code,
            speech_text_element,
            on_end_listening
        )

        listening = true

        web_speech_recognition.start_continuous()
    }

    function stop_listening(): void {
        if (!web_speech_recognition) return

        web_speech_recognition.stop()

        on_end_listening()
    }

    // function send_test_notification(): void {
    //     const title = 'Talk - Chat'
    //     const body = '通知テストです'
    //     const icon = '/icon-144.avif'
    //     const options = { body, icon }
    //     const notification = new Notification(title, options)

    //     console.log('send_notification')

    //     setTimeout(send_test_notification, 30 * 1000)

    //     //     notification.onclick = () => {
    //     //         console.log('notification.onclick')
    //     //     }

    //     //     notification.onclose = () => {
    //     //         console.log('notification.onclose')
    //     //     }

    //     //     notification.onerror = () => {
    //     //         console.log('notification.onerror')
    //     //     }

    //     //     notification.onshow = () => {
    //     //         console.log('notification.onshow')
    //     //     }
    // }

    async function enable_notification(): Promise<void> {
        web_logger.info(`enable_notification: name: ${name}`)

        const notification_permission = await Notification.requestPermission()

        if (notification_permission !== 'granted') {
            web_logger.info(`enable_notification: permission denied. name: ${name}`)
            alert($_('please_allow_notification'))

            return
        }

        is_notification_enabled = true
    }

    async function disable_notification(): Promise<void> {
        web_logger.info(`disable_notification: name: ${name}`)
        is_notification_enabled = false
    }

    function add_checking_background_events(): void {
        document.addEventListener('visibilitychange', () => {
            is_visible = document.visibilityState === 'visible'
        })

        window.addEventListener('focus', () => {
            is_visible = true
        })

        window.addEventListener('blur', () => {
            is_visible = false
        })
    }

    type JoinData = {
        room_id: string
        name: string
        user_id: number
        locale_code: string
        is_mobile_device: boolean
    }

    function join(): void {
        if (!name) {
            name_element.focus()

            return
        }

        const join_data: JoinData = {
            room_id: data.room_id,
            name: name,
            user_id: user_id.id,
            locale_code: locale_select_element.value,
            is_mobile_device: Web.is_mobile_device(),
        }

        socket.emit('join', join_data)

        joined = true

        setTimeout(() => {
            message_div_element.focus()
            observer = new ResizeObserver(on_chat_log_div_resize)
            observer.observe(chat_log_div_element)
            saved_chat_log_height = chat_log_div_element.clientHeight
        }, 50)
    }

    function on_click_join(): void {
        web_logger.info(`on_click_join: ${name}`)
        join()
    }

    function did_leave(): void {
        chat_log_items = []
        joined = false

        setTimeout(() => {
            name_element.focus()
        }, 50)
    }

    function leave(): void {
        web_logger.info(`leave: ${name}`)

        socket.emit('leave')

        did_leave()
    }

    // function register_service_worker(): void {
    //     if (!browser) return

    //     navigator.serviceWorker.register('/service-worker.js', {
    //         type: dev ? 'module' : 'classic',
    //     })
    // }

    function get_country_emoji(locale_code: LocaleCode): string {
        const locale = locales.find((locale) => locale.code === locale_code.code)

        if (!locale) return ''

        return locale.emoji
    }

    function is_scroll_at_bottom(): boolean {
        const allowed_pixels_from_bottom = 25

        const is_at_bottom =
            chat_log_div_element.scrollHeight -
                (chat_log_div_element.scrollTop + chat_log_div_element.clientHeight) <=
            allowed_pixels_from_bottom

        return is_at_bottom
    }

    function on_chat_log_div_resize(): void {
        let difference = 0

        if (!chat_log_div_element) return

        if (is_scroll_at_bottom()) {
            scroll_to_bottom()
        } else {
            difference = saved_chat_log_height - chat_log_div_element.clientHeight
            chat_log_div_element.scrollTop = chat_log_div_element.scrollTop + difference
        }

        saved_chat_log_height = chat_log_div_element.clientHeight
    }

    function new_room(): void {
        const room_id = uuidv4()

        goto(`${base}/chat/${room_id}`)
    }

    socket.on('connect', () => {
        // eslint-disable-next-line no-console
        console.debug('[socket.io] connected.')
    })

    socket.on('disconnect', () => {
        // eslint-disable-next-line no-console
        console.debug('[socket.io] disconnected.')
        did_leave()
    })

    socket.on('logs', async (received_chat_logs: ChatLog[]) => {
        const received_chat_log_items = received_chat_logs.map((chat_log) => {
            return {
                data: chat_log,
                translated: '',
            }
        })

        chat_log_items = received_chat_log_items.slice().reverse()

        await show_translation()
        scroll_to_bottom()
    })

    socket.on('message', async (received_chat_log: ChatLog) => {
        const translated_chat_log = {
            data: received_chat_log,
            translated: '',
        }

        const is_at_bottom = is_scroll_at_bottom()

        chat_log_items = [...chat_log_items, translated_chat_log]

        await show_translation()
        show_message_notification(translated_chat_log)

        if (is_at_bottom || Web.is_android()) {
            scroll_to_bottom()
        }
    })

    socket.on('members', (members: ChatMemberEntity[]) => {
        chat_member_entities = members
    })

    socket.on('join', (member: ChatMemberEntity) => {
        // console.debug('join', member.name)
        const notification_message = $_('joined', { values: { name: member.name } })

        setTimeout(() => {
            show_notification(notification_message)
        }, 50)
    })

    socket.on('leave', (member: ChatMemberEntity) => {
        // console.debug('leave', member.name)
        const notification_message = $_('leaved', { values: { name: member.name } })

        setTimeout(() => {
            show_notification(notification_message)
        }, 50)
    })

    socket.on('message_acknowledged', () => {
        sending = false
        message = ''
    })

    onMount(async () => {
        web_logger.add_event_listeners()
        add_checking_background_events()
        init_name()
        await init_locale()
        init_focus()
        // register_service_worker()
    })

    onDestroy(async () => {
        if (!observer) return

        observer.disconnect()
    })

    /* eslint-disable svelte/no-at-html-tags */
</script>

<svelte:head>
    <style>
        option {
            background-color: white !important;
        }
    </style>
</svelte:head>

<SocialMetaTags title={App.get_page_title('Chat')} description={App.description} />

<div class="flex h-screen min-h-screen flex-col">
    <Navbar />

    <div class="center-container flex w-screen flex-1 flex-col gap-3 overflow-y-auto p-3">
        <div class="flex items-center justify-between gap-3">
            <div class="glass-panel glass-text-4 flex h-[40px] w-full items-center gap-3 p-3">
                <div class="ml-1 font-bold">{$_('room')}:</div>
                <div data-testid="room-id">{data.room_id}</div>
            </div>
            {#if !joined}
                <button
                    class="glass-button glass-panel flex h-[40px] items-center overflow-visible border-primary-9/[0.13] hover:!shadow-sm"
                    on:click={new_room}
                    data-testid="new-room-button"
                >
                    <div class="flex flex-row items-center gap-1.5">
                        <div class="h-[24px] w-[24px]">
                            <AddIcon />
                        </div>
                        <div class="whitespace-nowrap">{$_('new_room')}</div>
                    </div>
                </button>
            {/if}
        </div>
        {#if joined}
            <div
                class="glass-panel flex flex-1 flex-col gap-3 overflow-y-auto p-3"
                bind:this={chat_log_div_element}
            >
                {#each chat_log_items as chat_log_item}
                    <div in:fly={{ y: 20 }} out:slide class="flex">
                        <div class="mr-4 w-10 pt-[2px]">
                            <img
                                class="w-full rounded-full object-contain"
                                src={new AvatarUrl(new UserId(chat_log_item.data.sender_id)).url}
                                alt="avatar"
                            />
                        </div>
                        <div>
                            <div class="flex flex-row gap-1">
                                <span class="font-bold" data-testid="chat_name">{chat_log_item.data.name}</span>
                                <span class="glass-text-faint">{to_local_time(chat_log_item.data.created_at)}</span>
                            </div>
                            {#if chat_log_item.translated}
                                <p>
                                    <span class="whitespace-pre-wrap" data-testid="translated_chat_message">
                                        {@html new Urlify(chat_log_item.translated).replace()}
                                    </span>
                                </p>
                                <div class="glass-text-faint flex flex-row gap-1">
                                    <span>{chat_log_item.data.locale_code}:</span>
                                    <span
                                        class="whitespace-pre-wrap"
                                        data-testid="chat_message"
                                        lang={chat_log_item.data.locale_code}
                                        dir={new Direction(chat_log_item.data.locale_code).value}
                                        >{chat_log_item.data.message}
                                    </span>
                                </div>
                            {:else}
                                <p>
                                    <span class="whitespace-pre-wrap" data-testid="chat_message">
                                        {@html new Urlify(chat_log_item.data.message).replace()}
                                    </span>
                                </p>
                            {/if}
                        </div>
                    </div>
                {/each}
            </div>
        {/if}

        <div class="glass-panel flex flex-col gap-3 p-3">
            {#if joined}
                <div class="flex flex-row flex-wrap gap-3">
                    <div class="flex flex-row flex-wrap gap-0.5">
                        <span class="h-[24px] w-[24px]">
                            <PersonIcon />
                        </span>
                        {chat_member_entities.length}
                    </div>
                    {#each chat_member_entities as chat_member}
                        {@const locale_code = new LocaleCode(chat_member.locale_code)}
                        <div class="flex flex-row flex-wrap items-center gap-1" in:fly={{ y: 20 }} out:slide>
                            <span>{get_country_emoji(locale_code)}</span>
                            <span class="h-4 w-4"
                                >{#if chat_member.is_mobile_device}<PhoneAndroidIcon />{:else}<DesktopWindowsIcon
                                    />{/if}</span
                            >
                            <span>{chat_member.name}</span>
                        </div>
                    {/each}
                </div>

                <div class="input flex flex-col gap-1 p-1">
                    <div
                        contenteditable="true"
                        role="textbox"
                        tabindex="0"
                        class="px-3 py-1 outline-none"
                        placeholder={$_('enter_new_text')}
                        bind:this={message_div_element}
                        bind:innerText={message}
                        on:keydown={on_keydown_message}
                    />
                    <div class="flex flex-row">
                        <div class="flex-1">
                            {#if listening}
                                <IconButton on:click={stop_listening}>
                                    <StopIcon />
                                </IconButton>
                            {:else}
                                <IconButton on:click={start_listening}>
                                    <VoiceIcon />
                                </IconButton>
                            {/if}
                        </div>
                        <div>
                            {#if sending}
                                <div class="animate-spin">
                                    <IconButton><LoadingIcon /></IconButton>
                                </div>
                            {:else}
                                <IconButton on:click={on_click_send} enabled={can_send}><FillIcon /></IconButton>
                            {/if}
                        </div>
                    </div>
                </div>
            {/if}

            <div class="flex flex-wrap items-center gap-3">
                <select
                    class="glass-button text-center"
                    bind:this={locale_select_element}
                    on:change={on_change_locale_select}
                    disabled={joined}
                />

                <input
                    class="input grow"
                    type="text"
                    bind:this={name_element}
                    bind:value={name}
                    placeholder={$_('name')}
                    on:keydown={on_keydown_name}
                    on:change={on_change_name}
                    disabled={joined}
                />

                {#if joined}
                    {#if is_notification_enabled}
                        <button class="glass-button" on:click={disable_notification}>
                            <div class="flex flex-row items-center gap-1.5">
                                <div class="h-[24px] w-[24px]">
                                    <NotificationsActiveIcon />
                                </div>
                            </div>
                        </button>
                    {:else}
                        <button class="glass-button" on:click={enable_notification}>
                            <div class="flex flex-row items-center gap-1.5">
                                <div class="h-[24px] w-[24px]">
                                    <NotificationsIcon />
                                </div>
                            </div>
                        </button>
                    {/if}

                    <button class="glass-button" on:click={leave}>
                        <div class="flex flex-row items-center gap-1.5">
                            <div class="h-[24px] w-[24px]">
                                <SignOutIcon />
                            </div>
                        </div>
                    </button>
                {:else}
                    <button class="glass-button" on:click={on_click_join}>
                        <div class="flex flex-row items-center gap-1.5">
                            <div class="h-[24px] w-[24px]">
                                <SignInIcon />
                            </div>
                        </div>
                    </button>
                {/if}
            </div>
        </div>
        <VersionFooter />
    </div>
</div>