diff --git a/package.json b/package.json index 3a75bac5944682d07020942a1316ca12892dc128..ed7155dc92be823c5965aaca0a5fa2bdceea30e6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "chatterbox", - "version": "0.2.2", + "version": "0.2.3", "scripts": { "start": "vite", "build": "tsc && vite build && vite build --config parent-vite.config.js && scripts/after-build.sh", diff --git a/src/main.ts b/src/main.ts index 9c3c83e1a26c778ca955733e81ba0c32ad4a9340..cd2338aad2b43582e593c32f474b5430e98c25cc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -29,6 +29,10 @@ async function fetchConfig(): Promise<IChatterboxConfig> { return config; } +function shouldStartMinimized(): boolean { + return !!new URLSearchParams(window.location.search).get("minimized"); +} + async function main() { const root = document.querySelector(rootDivId) as HTMLDivElement; if (!root) { @@ -40,7 +44,8 @@ async function main() { const navigation = new Navigation(allowsChild); platform.setNavigation(navigation); const urlRouter = createRouter({ navigation, history: platform.history }); - const rootViewModel = new RootViewModel(config, {platform, navigation, urlCreator: urlRouter}); + const startMinimized = shouldStartMinimized(); + const rootViewModel = new RootViewModel(config, {platform, navigation, urlCreator: urlRouter, startMinimized}); rootViewModel.start(); const rootView = new RootView(rootViewModel); root.appendChild(rootView.mount()); @@ -67,4 +72,8 @@ function allowsChild(parent, child) { window.parent?.postMessage({ action: "minimize" }, "*"); }; +(window as any).sendNotificationCount = function (count: number) { + window.parent?.postMessage({ action: "unread-message", count }, "*"); +}; + main(); diff --git a/src/observables/MessageFromParent.ts b/src/observables/MessageFromParent.ts new file mode 100644 index 0000000000000000000000000000000000000000..7b5446525e5be993224f14b59002512c5c9159ed --- /dev/null +++ b/src/observables/MessageFromParent.ts @@ -0,0 +1,11 @@ +import { EventEmitter } from "hydrogen-view-sdk"; + +export class MessageFromParent extends EventEmitter { + constructor() { + super(); + window.addEventListener("message", (event) => { + const { action } = event.data; + this.emit(action, event.data); + }); + } +} diff --git a/src/parent/iframe.ts b/src/parent/iframe.ts index 28880132b3440415a843c4e125475befcc96c657..5b35ac74873419f2bf6efae0aaa5ff965511b559 100644 --- a/src/parent/iframe.ts +++ b/src/parent/iframe.ts @@ -17,11 +17,13 @@ export function toggleIframe() { if (iframeElement.style.display !== "none") { iframeElement.style.display = "none"; document.querySelector(".start-chat-btn").classList.remove("start-background-minimized"); + iframeElement.contentWindow.postMessage({ action: "minimize" }, "*");; if (isMobile()) { startButtonDiv.style.display = "block"; } } else { + iframeElement.contentWindow.postMessage({ action: "maximize" }, "*");; iframeElement.style.display = "block"; document.querySelector(".start-chat-btn").classList.add("start-background-minimized"); if (isMobile()) { diff --git a/src/parent/load.ts b/src/parent/load.ts index a4ec55526aaa067331ceaa02d7e333a4e3e4d3fd..59d7c3d4a9874737273bb8f49c49a54d89ffd2c5 100644 --- a/src/parent/load.ts +++ b/src/parent/load.ts @@ -8,11 +8,31 @@ export function loadStartButton() { loadCSS(); const container = document.createElement("div"); container.className = "start"; + const button = createStartButton(); + container.appendChild(button); + document.body.appendChild(container); + if (window.localStorage.getItem("chatterbox-should-load-in-background")) { + /** + * If chatterbox made it to the timeline before, load the chatterbox app in background. + * This will let us watch for new messages and show a notification badge as needed. + */ + loadIframe(true); + toggleIframe(); + } +} + +function createStartButton() { const button = document.createElement("button"); button.className = "start-chat-btn"; button.onclick = () => (window as any).isIframeLoaded? toggleIframe() : loadIframe(); - container.appendChild(button); - document.body.appendChild(container); + button.appendChild(createNotificationBadge()); + return button; +} + +function createNotificationBadge() { + const notificationBadge = document.createElement("span"); + notificationBadge.className = "notification-badge hidden"; + return notificationBadge; } function loadCSS() { @@ -22,22 +42,20 @@ function loadCSS() { document.head.appendChild(linkElement); } -function loadIframe() { +function loadIframe(minimized = false) { const iframe = document.createElement("iframe"); const configLocation = (window as any).CHATTERBOX_CONFIG_LOCATION; if (!configLocation) { throw new Error("CHATTERBOX_CONFIG_LOCATION is not set"); } iframe.src = new URL( - "../chatterbox.html?config=" + configLocation, + `../chatterbox.html?config=${configLocation}${minimized? "&minimized=true": ""}`, hostRoot ).href; iframe.className = "chatterbox-iframe"; document.body.appendChild(iframe); (window as any).isIframeLoaded = true; - document - .querySelector(".start-chat-btn") - .classList.add("start-background-minimized"); + document .querySelector(".start-chat-btn") .classList.add("start-background-minimized"); if (isMobile()) { (document.querySelector(".start") as HTMLDivElement).style.display = "none"; diff --git a/src/parent/parent-style.css b/src/parent/parent-style.css index a6eaa88954bde09942c8136bfc8c26a8a77fed01..ec905c358f6a0428c941c13048990565012f86e3 100644 --- a/src/parent/parent-style.css +++ b/src/parent/parent-style.css @@ -39,3 +39,24 @@ .start-background-minimized { background: no-repeat center url("../ui/res/chevron-down-button.svg"), linear-gradient(180deg, #7657F2 0%, #5C56F5 100%); } + +.notification-badge { + position: absolute; + width: 20px; + height: 20px; + color: white; + background-color: #FF5B55; + left: 47px; + bottom: 49px; + border-radius: 100%; + font-size: 12px; + font-weight: bold; + display: flex; + justify-content: center; + align-items: center; + box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.25); +} + +.hidden { + display: none; +} diff --git a/src/parent/parent.ts b/src/parent/parent.ts index ab7b50725fe6bc9d89314920365a2c521b941a11..5836df9b89e699f949182bfdc9412b32003d5bb1 100644 --- a/src/parent/parent.ts +++ b/src/parent/parent.ts @@ -4,15 +4,35 @@ import "./parent-style.css"; (window as any).isIframeLoaded = false; +function setUnreadCount(count) { + const notification = document.querySelector(".notification-badge") as HTMLSpanElement; + if (count === 0) { + notification.classList.add("hidden"); + } + else { + notification.innerText = count; + notification.classList.remove("hidden"); + } +} + window.addEventListener("message", event => { const { action } = event.data; switch (action) { case "resize-iframe": + if (event.data.view === "timeline") { + // Chatterbox has made it to the timeline! + // Store this is info in localStorage so that we know to load chatterbox in background + // in subsequent visits. + window.localStorage.setItem("chatterbox-should-load-in-background", "true"); + } resizeIframe(event.data); break; case "minimize": toggleIframe(); break; + case "unread-message": + setUnreadCount(event.data.count); + break; } }); diff --git a/src/ui/views/ChatterboxView.ts b/src/ui/views/ChatterboxView.ts index ac40e13a57af3f49ea240a40966a5596d0476d4b..c7e12bb280096e13529642ea206207208ae82a50 100644 --- a/src/ui/views/ChatterboxView.ts +++ b/src/ui/views/ChatterboxView.ts @@ -10,7 +10,8 @@ export class ChatterboxView extends TemplateView<ChatterboxViewModel> { } render(t) { - return t.div({ className: "ChatterboxView" }, [ + return t.div({ className: "ChatterboxView", }, + [ t.mapView( (vm) => (vm.roomViewModel ? vm : null), (vm) => (vm ? new RoomHeaderView(vm) : null) @@ -39,7 +40,11 @@ class RoomHeaderView extends TemplateView<ChatterboxViewModel> { avatar, t.div({ className: "RoomHeaderView_name" }, vm => vm.roomName), t.div({ className: "RoomHeaderView_menu" }, [ - t.button({ className: "RoomHeaderView_menu_minimize", onClick: () => (window as any).sendMinimizeToParent() }) + t.button({ + className: "RoomHeaderView_menu_minimize", onClick: () => { + vm.minimize(); + } + }) ]), ]); } diff --git a/src/viewmodels/ChatterboxViewModel.ts b/src/viewmodels/ChatterboxViewModel.ts index 55caf2053df667bc08f20bb06586456debedde37..e8fcd0180118db75dab395bf2efc948994145d58 100644 --- a/src/viewmodels/ChatterboxViewModel.ts +++ b/src/viewmodels/ChatterboxViewModel.ts @@ -3,11 +3,13 @@ import { RoomViewModel, ViewModel, RoomStatus} from "hydrogen-view-sdk"; export class ChatterboxViewModel extends ViewModel { private _roomViewModel?: typeof RoomViewModel; private _loginPromise: Promise<void>; + private _minimize: () => void; constructor(options) { super(options); this._client = options.client; this._loginPromise = options.loginPromise; + this._minimize = options.minimize; } async load() { @@ -22,13 +24,13 @@ export class ChatterboxViewModel extends ViewModel { else { throw new Error("ConfigError: You must either specify 'invite_user' or 'auto_join_room'"); } - this._roomViewModel = new RoomViewModel({ + this._roomViewModel = this.track(new RoomViewModel({ room, ownUserId: this._session.userId, platform: this.platform, urlCreator: this.urlCreator, navigation: this.navigation, - }); + })); await this._roomViewModel.load(); this.emitChange("roomViewModel"); } @@ -87,6 +89,11 @@ export class ChatterboxViewModel extends ViewModel { return promise; } + minimize() { + (window as any).sendMinimizeToParent(); + this._minimize(); + } + get timelineViewModel() { return this._roomViewModel?.timelineViewModel; } diff --git a/src/viewmodels/RootViewModel.ts b/src/viewmodels/RootViewModel.ts index b4f5484c6fd5058fcbc1622dade8ad366c3f5412..ae2f3463e9fb31fbda513a7012dab6a13f7fd4de 100644 --- a/src/viewmodels/RootViewModel.ts +++ b/src/viewmodels/RootViewModel.ts @@ -1,10 +1,11 @@ -import { ViewModel, Client, Navigation, createRouter, Platform } from "hydrogen-view-sdk"; +import { ViewModel, Client, Navigation, createRouter, Platform } from "hydrogen-view-sdk"; import { IChatterboxConfig } from "../types/IChatterboxConfig"; import { ChatterboxViewModel } from "./ChatterboxViewModel"; import "hydrogen-view-sdk/style.css"; import { AccountSetupViewModel } from "./AccountSetupViewModel"; +import { MessageFromParent } from "../observables/MessageFromParent"; -type Options = { platform: typeof Platform, navigation: typeof Navigation, urlCreator: ReturnType<typeof createRouter> }; +type Options = { platform: typeof Platform, navigation: typeof Navigation, urlCreator: ReturnType<typeof createRouter>, startMinimized: boolean }; export class RootViewModel extends ViewModel { private _config: IChatterboxConfig; @@ -12,12 +13,19 @@ export class RootViewModel extends ViewModel { private _chatterBoxViewModel?: ChatterboxViewModel; private _accountSetupViewModel?: AccountSetupViewModel; private _activeSection?: string; + private _messageFromParent: MessageFromParent = new MessageFromParent(); + private _startMinimized: boolean; + private _isWatchingNotificationCount: boolean; constructor(config: IChatterboxConfig, options: Options) { super(options); + this._startMinimized = options.startMinimized; this._config = config; this._client = new Client(this.platform); this._setupNavigation(); + this._messageFromParent.on("maximize", () => this._showTimeline(Promise.resolve())); + // Chatterbox can be minimized via the start button on the parent page! + this._messageFromParent.on("minimize", () => this.minimizeChatterbox()); } private _setupNavigation() { @@ -28,6 +36,10 @@ export class RootViewModel extends ViewModel { async start() { const sessionAlreadyExists = await this.attemptStartWithExistingSession(); if (sessionAlreadyExists) { + this._watchNotificationCount(); + if (this._startMinimized) { + return; + } this.navigation.push("timeline"); return; } @@ -43,9 +55,14 @@ export class RootViewModel extends ViewModel { config: this._config, state: this._state, loginPromise, + minimize: () => this.minimizeChatterbox() }) )); - this._chatterBoxViewModel.load(); + await this._chatterBoxViewModel.load(); + if (!this._isWatchingNotificationCount) { + // for when chatterbox is loaded initially + this._watchNotificationCount(); + } } this.emitChange("activeSection"); } @@ -77,6 +94,38 @@ export class RootViewModel extends ViewModel { return false; } + private _watchNotificationCount() { + const [room] = this._client.session.rooms.values(); + let previousCount = room.notificationCount; + (window as any).sendNotificationCount(previousCount); + const subscription = { + onUpdate(_: unknown, room) { + const newCount = room.notificationCount; + if (newCount !== previousCount) { + if (!room.isUnread && newCount !== 0) { + /* + when chatterbox is maximized and there are previous unread messages, + this condition is hit but we still want to send the notification count so that + the badge zeroes out. + */ + room.clearUnread(); + return; + } + (window as any).sendNotificationCount(newCount); + previousCount = newCount; + } + }, + }; + this.track(this._client.session.rooms.subscribe(subscription)); + this._isWatchingNotificationCount = true; + } + + minimizeChatterbox() { + this._chatterBoxViewModel = this.disposeTracked(this._chatterBoxViewModel); + this._activeSection = ""; + this.emitChange("chatterboxViewModel"); + } + get chatterboxViewModel() { return this._chatterBoxViewModel; }