Combining transitions with reactive content

76 Views Asked by At

I'm working on a chat application in SvelteKit. When the user sends a message, the user message should slide into the chatlog, and then an ellipsis should slide into the chatlog. When the chat response is ready, the ellipsis should be replaced by the response message, and it should show up using a typewriter transition.

I've solved this by having a messages array, and a messageCount variable. When the user sends a message, both the message and the ellipsis is appended to messages, and messageCount is incremented. The {#each} block showing the chatlog slices messages so that only the first messageCount messages are shown. When the user message has been transitioned in, messageCount is incremented again using on:introend, so that the ellipsis slides in.

The message content is in a <span> with a typewriter transition. It is wrapped in a {#key} block to ensure that the transition is run when the ellipsis is replaced by the actual chat response message.

This works well as long as the ellipsis comes into the chatlog before it is replaced by the actual chat response message. If the slide transition takes longer than the delay before the ellipsis is replaced, the typewriter transition does not take place. Why is that?

Here is the most important logic:

function sendMessage() {
    messages = [
        ...messages,
        { "role": "user", "content": message },
        { "role": "assistant", "content": "…" },
    ];
    setTimeout(() => {
        messages = [
            ...messages.slice(0, messages.length - 1),
            { "role": "assistant", "content": "Response to chat message" },
        ];
    }, 1000); // Set delay to 100, and the typewriter transition will stop working.
    ++messageCount;
}
{#each messages.slice(0, messageCount) as message}
    <ul>
        <li in:slide on:introend={showNextMessage}>
            <!-- Remove the {#key} block and the typewriter transition stops working for delay 1000, but starts working for delay 100 -->
            {#key message.content}
                <span in:typewriter={message.role === "user" ? Infinity : 25}>
                    {message.content}
                </span>
            {/key}
        </li>
    </ul>
{/each}

Here is a working REPL: https://svelte.dev/repl/9331addfc5b940dd85ff6cbdc0565c76?version=4.2.1

2

There are 2 best solutions below

0
Magnar Myrtveit On BEST ANSWER

The solution was a simple as adding the global modifier to the transition:

{#each messages.slice(0, messageCount) as message}
    <ul>
        <li in:slide on:introend={showNextMessage}>
            <!-- Remove the {#key} block and the typewriter transition stops working for delay 1000, but starts working for delay 100 -->
            {#key message.content}
                <span in:typewriter|global={message.role === "user" ? Infinity : 25}>
                    {message.content}
                </span>
            {/key}
        </li>
    </ul>
{/each}
1
Carlos Daniel Vilaseca On

the simplest workaround could be to update the innerHTML of the span dynamically

<script>
    import { slide } from "svelte/transition";
    let message = "Some message";
    let messages = [];
    
    const typing = (span, responseMessage) => {
        let i = 0
        span.innerHTML = "..."
        setTimeout(()=>{
            span.innerHTML = ""
            const intervalID = setInterval(()=>{
                span.innerHTML += responseMessage.charAt(i++)
                if(i == responseMessage.length) clearInterval(intervalID)
            },20)
        },1000)
    }

    function sendMessage() {
        messages = [
            ...messages,
            {role: "user", content: message},
            {role: "assistant", content: ""}
        ]
    } 

    const getResponseMessage = async(id) =>  {
        const responses = [ //from some database, or api whatever
            "this is the response message 1",
            "this is the response message 2",
            "this is the response message 3",
            "this is the response message 4",
            "this is the response message 5",
            "this is the response message 6",
            "this is the response message 7",
            "this is the response message 8",
            "this is the response message 9",
        ]
        return new Promise((accept)=>{
            setTimeout(()=>{
                accept(responses.find((_,index)=>id===index) ?? "this is the default message")
            },1000)
        })
    }
</script>

<textarea bind:value={message}></textarea>
<button on:click={sendMessage}>Send message</button>
<ul>
    {#each messages as {content,role},index}
            <li in:slide>
                {#if role === 'user'}
                    <span>
                        {content}
                    </span>
                {:else}
                    {#await getResponseMessage(index)}
                        ...
                    {:then response}
                        <span use:typing={response}/>
                    {/await}            
                {/if}
            </li>
    {/each}
</ul>

<style>
    li {
        background: pink;
        color: black;
    }
</style>

this uses typing in the use directive to update the span text with the typing animation once the promise was solved. I'm taking advantage of the svelte await block, but I guess you could also use a variable.

and is also a good idea to always have an id to use in a keyed each Stephane Vanraes said.