Interkit docs

ElementList

usage

Displays a chat interface for a given channel.

The channel key can be set in two ways:

  • using the channel_key prop
  • using the globalStore “chatChannelKey”

If the globalStore is set, it takes priority.

Props

Prop Type Default Description
typingHideTypes string ''
typingDurationTypeTextPerCharacter string '0.075'
typingDurationTypeChoiceDefault string '0'
channel_key string "DEFAULT"
messagesReportableDefault string 'TRUE'

Source

<script>

  import { onMount, onDestroy, tick, beforeUpdate, afterUpdate } from "svelte"
  import { get } from "svelte/store"
  import { InterkitClient } from "../"
  import Message from './Chat/Message.svelte'
  import MessageTyping from './Chat/MessageTyping.svelte'
  import ChatInput from './Chat/ChatInput.svelte'
  import ChatChannelImage from "./Chat/ChatChannelImage.svelte"

  import { Plugins } from '@capacitor/core';
  import { decimalToSexagesimal } from "geolib";

  const verbose = false

  const projectDataStore = InterkitClient.userProjectDataStore

  const { Geolocation } = Plugins;

  export let channel_key = "DEFAULT"
  export let messagesReportableDefault = 'TRUE'

  // limited by typingMaxDuration!
  export let typingDurationTypeChoiceDefault = '0'
  typingDurationTypeChoiceDefault = (+typingDurationTypeChoiceDefault) || 0

  // limited by typingDurationTypeTextMin!
  export let typingDurationTypeTextPerCharacter = '0.075'
  typingDurationTypeTextPerCharacter = (+typingDurationTypeTextPerCharacter) || 0.075

  // comma-separated string of message types, for which to hide typing dots. e.g. text,choice
  export let typingHideTypes = ''
  typingHideTypes = typingHideTypes.split(',').map(_ => _.trim())

  const reportsChannelKey = 'REPORTS'

  let sub;
  let messageStore;
  let userId;
  let userSub;
  let userStore;
  let projectId;

  let typingQueuePointer

  onMount(async () => {

    // if a globalStore has been set, use that
    let channelKeyDynamic = InterkitClient.getGlobalStore("chatChannelKey");
    if(get(channelKeyDynamic)) {
      channel_key = get(channelKeyDynamic)
    }    

    console.log("getting sub with channel", channel_key)

    sub = await InterkitClient.getMessageSub(channel_key);
    messageStore = sub.data

    userId = get(InterkitClient.userId)
    console.log("Chat userId", userId)
    projectId = get(InterkitClient.projectId)
    console.log("Chat projectId", projectId)

    userSub = await InterkitClient.getSub('users', 'user')
    userStore = userSub.data

    // scrollDown()
    // window.setTimeout(() => { scrollDown() }, 500)
  })

  onDestroy(async () => {
    /*
    // this creates problems on resubscription
    if(sub)
      await sub.sub.stop();
    */
  })

  let storeUpdates = 0

  /* Messages can have a `setInterface` option in their payload,
   * which is evaluated in the typing queue.
   * So when messages arrive while the app is not visible,
   * those `setInterfaces`s won't be evaluated.
   * We have to "replay" the last "ignored" message.
   * FIXME this is an overcomplicated fix to a problem that
   * should not exist in the first place.
   * TODO stick most of the console.logs behind `verbose` once tested.
   */
  const fastforwardOptionSetInterfaces = () => {
    if (!hasStoreUpdated && hasInterfaceUpdated) {
      console.log('fastforwardOptionSetInterfaces: interface updated, store not yet, bail')
      return
    }
    if (hasStoreUpdated && !hasInterfaceUpdated) {
      console.log('fastforwardOptionSetInterfaces: store updated, interface not yet, bail')
      return
    }
    if (hasFastforwarded) {
      console.log('fastforwardOptionSetInterfaces: done already, bail')
      return
    }
    hasFastforwarded = true
    let newestMessageWithSetInterface
    $messageStore?.forEach(message => {
      if (!message.payload?.options?.setInterface) return
      if (!newestMessageWithSetInterface || message.createdAt > newestMessageWithSetInterface.createdAt) {
        newestMessageWithSetInterface = message
      }
    })
    if (!newestMessageWithSetInterface) {
      console.log('fastforwardOptionSetInterfaces: no message with setInterface found, bail')
      return
    } else {
      console.log('fastforwardOptionSetInterfaces found newest message:', newestMessageWithSetInterface)
    }
    if (!chatInterface._updatedAt) {
      console.log('fastforwardOptionSetInterfaces: chatInterface sub did not provide _updatedAt, defaulting to newest message', newestMessageWithSetInterface)
      setChatInterface(
        newestMessageWithSetInterface.payload.options.setInterface,
        newestMessageWithSetInterface.createdAt
      )
      return
    }
    if (newestMessageWithSetInterface.createdAt > chatInterface._updatedAt) {
      console.log(
        'fastforwardOptionSetInterfaces: newest message is newer than chatInterface _updatedAt, updating',
        newestMessageWithSetInterface.createdAt, '>', chatInterface._updatedAt,
        newestMessageWithSetInterface
      )
      setChatInterface(
        newestMessageWithSetInterface.payload.options.setInterface,
        newestMessageWithSetInterface.createdAt
      )
    } else {
      console.log('fastforwardOptionSetInterfaces: newest message is older than chatInterface _updatedAt, ignoring')
    }
  }

  $: {
    if ($messageStore) {
      $messageStore = $messageStore.sort((a, b) => a.createdAt - b.createdAt)
      hasStoreUpdated = true
      fastforwardOptionSetInterfaces()
      console.log("message update", storeUpdates) // $messageStore)
      if (storeUpdates === 0) {
        typingQueuePointer = $messageStore.length
      }
      storeUpdates++
      //scrollDown()
      
      // mark all in channel as seen
      // setTimeout required for autoplay of unseen messages
      setTimeout(()=>{
          InterkitClient.call("channel.seeAll", {userId, channel_key});
        },
        2000
      )
      
    }
  }

  $: showInputField = (chatInterface?.text || chatInterface?.photo) && !$userStore?.[0]?.blocked

  /* fastforwardOptionSetInterfaces has to run once the two async subs/stores
   * for interface AND messages have updated at least once.
   * We use these flags to check when this has happened;
   * the third one tells us to run fastforward… only once.
   * TODO Not elegant, could maybe be done more idiomatically?
   */
  let hasInterfaceUpdated = false
  let hasStoreUpdated = false
  let hasFastforwarded = false

  // initialize chat interface and watch user data for changes
  const defaultChatInterface = {
    text: true
  }
  let chatInterface = defaultChatInterface;
  const updateChatInterface = (config) => {
    if(!config) {
      chatInterface = defaultChatInterface
    } else {
      chatInterface = config
    }
    console.log("chatInterface updated", chatInterface)
  }
  const userProjectData = InterkitClient.userProjectDataStore;
  $: {
    console.log("userProjectDataStore updated", $userProjectData)
    updateChatInterface($userProjectData?.boardState?.[channel_key]?.interfaceConfig)
    hasInterfaceUpdated = true
    fastforwardOptionSetInterfaces()
  }

  const setChatInterface = async (interfaceConfig, setUpdatedAt) => {
    if (setUpdatedAt) interfaceConfig._updatedAt = setUpdatedAt
    await InterkitClient.call('user.setBoardInterface', {
      interfaceConfig,
      projectId,
      userId,
      boardId: channel_key
    })
  }

  let messagesScrollContainer
  const autoscrollOffsetPx = 40

  const getScrollOffset = () => {
    if(!messagesScrollContainer) return 0;
    const {scrollHeight, scrollTop, clientHeight} = messagesScrollContainer
    const offset = scrollHeight - scrollTop - clientHeight
    return offset;
  }

  let doAutoScroll = false;

  beforeUpdate(() => {
    const offset = getScrollOffset();
    console.log("beforeUpdate", offset)
    doAutoScroll = offset < autoscrollOffsetPx;
  });

  afterUpdate(() => {
    console.log("afterUpdate", getScrollOffset())
    scrollDown();
  });
 
  const scrollDown = async () => {

    if(doAutoScroll) {
      const behavior = storeUpdates <= 1 ? 'instant' : 'smooth'
      //console.log('scrollDown', { behavior })
      await tick()
      
      const top = messagesScrollContainer?.scrollHeight
      messagesScrollContainer?.scrollTo({ top, behavior })
    }
  }

  const sendMessage = (messageText) => {
    InterkitClient.call("message.send", {
      sender: userId,
      channel_key, 
      payload: {type: "text", text: messageText},
      origin: "user"
    })
  }

  const sendImage = (imageKey) => {
    InterkitClient.call("message.send", {
      sender: userId,
      channel_key, 
      payload: {type: "image", mediafileKey: imageKey},
      origin: "user"
    })
  }

  const submitChoice = (message, selectedKey) => {
    if(!message?.selectedChoiceKey) {
      console.log("selected", selectedKey, message)
      InterkitClient.call("message.submitChoice", {
        sender: userId,
        channel_key, 
        messageId: message.id,
        selectedKey
      })
    }
  }
  
  const submitLocation = async (message, canceled = false) => {
    let location;
    let error;
    if(!canceled) {
      try {
        location = await Geolocation.getCurrentPosition();
      } 
      catch(e) {
        alert("Error obtaining geolocation. You may need to give the app permission.")
        error = e;
        return false;
      }
      console.log("sending location", location)
    }    
    await InterkitClient.call("message.submitLocation", {
      sender: userId,
      channel_key, 
      messageId: message.id,
      location: location ? {lng: location.coords.longitude, lat: location.coords.latitude} : undefined,
      canceled
    })
    return true
  }

  const sendReport = async (message) => {
    const reportText = 'user reported message:\n\n' + JSON.stringify(message)
    // console.log('reporting', { reportText, reportsChannelKey, sender: userId })
    let ret = await InterkitClient.call("message.send", {
      sender: userId,
      channel_key: reportsChannelKey,
      payload: {
        type: "text",
        text: reportText
      },
      origin: "user"
    })
    // console.log('report call ret', ret)
  }

  let typingShow = false
  let typingMessage;
  let typingMaxDuration
  $: typingMaxDuration = $projectDataStore?.userVars?.debugTurboMode ? 2 : 15
  const typingDurationTypeTextMin = 1

  const getTypingDuration = message => {
    if (message.payload && ('typingDuration' in message.payload)) {
      return message.payload.typingDuration
    }
    if (message.payload?.options && ('typingDuration' in message.payload.options)) {
      return message.payload.options.typingDuration
    }
    let duration
    switch (message?.payload?.type) {
      case 'text':
        duration = Math.max(
          typingDurationTypeTextMin,
          (message.payload?.text?.length * typingDurationTypeTextPerCharacter) || 0
        )
        break
      case 'choice':
        duration = typingDurationTypeChoiceDefault
        break
      case 'image':
        duration = 3
        break
      case 'system':
        duration = 0
        break
      default:
        duration = 1
    }
    return Math.min(duration, typingMaxDuration)
  }

  const typingNext = () => {
    if (verbose) console.log('typingNext')
    if (typingShow) {
      if (verbose) console.log('typingNext bailing typingShow')
      return
    }
    if (storeUpdates === 0) {
      if (verbose) console.log('typingNext bailing because first storeUpdate')
      return
    }
    if (typingQueuePointer >= $messageStore.length) {
      if (verbose) console.log('typingNext bailing QP >= store.length')
      return
    }
    const currentMessage = $messageStore[typingQueuePointer]
    if (!currentMessage) {
      if (verbose) console.warn('typingNext bailing because no currentMessage')
      return
    }
    if (currentMessage?.sender === userId) {
      if (verbose) console.log('typingNext skipping because user message')
      typingQueuePointer++
      setTimeout(typingNext, Math.floor(Math.random() * 500) +  750) // first reply
      return
    }
    const duration = getTypingDuration(currentMessage)
    typingShow = !typingHideTypes.includes(currentMessage?.payload?.type)
    if (verbose) console.log('typingNext starting timeout', duration, currentMessage, typingShow)
    typingMessage = currentMessage;
    window.setTimeout(() => {
      const interfaceConfig = typingMessage?.payload?.options?.setInterface
      if (interfaceConfig) {
        console.log('Chat message has setInterface, calling…', typingMessage, interfaceConfig)
        setChatInterface(
          interfaceConfig,
          typingMessage.createdAt
        )
      }
      typingShow = false
      typingQueuePointer++
      if (verbose) console.log('typingNext done timeout', { typingQueuePointer })
      setTimeout(typingNext, Math.floor(Math.random() * 500) +  750) // following supplies
    }, duration * 1000)
  }

  $: if ($messageStore) typingNext()
  //$: if (typingShow) scrollDown()

</script>

<div class="Chat root">
  <div class="channel-info-overlay">
    <!--span>channel {channel_key}</span-->
    <ChatChannelImage channel_key={channel_key}/>
  </div>
  <div
    class="messages-container"
    class:messages__empty={!messageStore || $messageStore.length === 0}
    bind:this={messagesScrollContainer}
    >
    {#if messageStore}
      <div class="messages">
        {#each $messageStore as message, index}
          {#if index < typingQueuePointer}
            <Message 
              {message} 
              {submitChoice} 
              {submitLocation}
              isByUser={message?.sender === userId} 
              lastFromSender={message.sender !== $messageStore[index+1]?.sender || !$messageStore[index+1]}
              previousMessage={$messageStore[index-1]}
              isReportable={message?.payload?.options?.reportable !== undefined
                ? message?.payload?.options?.reportable
                : (messagesReportableDefault === 'TRUE')
              }
              on:report={ event => sendReport(event.detail.message) }
              on:mounted={() => { /*scrollDown()*/ }}
              />
          {/if}
        {/each}
        <MessageTyping show={typingShow} message={typingMessage} />
        {#if $userStore?.[0]?.blocked}
          <div class="blocked">
            Du bist geblockt, vielleicht weil du gegen die Community-Richtlinien verstoßen hast. Klicke oben auf das Fragezeichen um die Richtlinien einzusehen. Dort findest du auch Kontaktdaten.
          </div>
        {/if}
      </div>
    {/if}
  </div>
  <div
    class="input"
    class:hidden={!showInputField}
    >
    <ChatInput 
      {chatInterface} 
      userId={userId}
      boardId={channel_key}
      nodeId={channel_key ? $userProjectData?.boardState?.[channel_key]?.nodeId : undefined}
      on:submit={ event => sendMessage(event.detail.messageText)} 
      on:imageSubmit={ event => sendImage(event.detail.imageKey) }
    />
  </div>
</div>

<style>

  .root {
    display: flex;
    flex-direction: column;
    height: 100%;
    background-color: var(--color-background-highlight);
  }

  .channel-info-overlay {
    position: absolute;
    width: 96px;
    height: 96px;
    top: var(--distance-m);
    left: var(--distance-s);
    z-index: 1;
  }

  .messages-container {
    flex-grow: 1;
    flex-shrink: 1;
    overflow-x: hidden;
    overflow-y: scroll;
  }

  .messages {
    display: flex;
    flex-direction: column;
    padding: calc(96px + var(--distance-l) ) var(--distance-m) var(--distance-m) var(--distance-m);
  }

  .input {
    flex-grow: 0;
    flex-shrink: 1;
    border-top: 1px solid var(--color-border);
  }

  .input.hidden {
    visibility: hidden;
  }

</style>