diff --git a/doc/viewhierarchy.md b/doc/viewhierarchy.md index d9c0f967..0605297d 100644 --- a/doc/viewhierarchy.md +++ b/doc/viewhierarchy.md @@ -1,9 +1,9 @@ view hierarchy: ``` BrawlView - SyncStatusBar SwitchView SessionView + SyncStatusBar ListView(left-panel) SwitchView RoomPlaceholderView @@ -12,4 +12,15 @@ view hierarchy: ListView(timeline) ComposerView RightPanel + SessionStartView + SessionPickView + LoginView ``` + + - DONE: support isOwn on message view model + - DONE: put syncstatusbar in sessionview + - DONE: apply css to app + - DONE: keep scroll at bottom + - DONE: hide sender if repeated + - DONE: show date somehow + - DONE: start scrolled down when opening room diff --git a/index.html b/index.html index 477d7c05..8ae1e5cf 100644 --- a/index.html +++ b/index.html @@ -2,68 +2,12 @@ - + -

-
-
diff --git a/src/EventEmitter.js b/src/EventEmitter.js index 460d75a0..27a4e17d 100644 --- a/src/EventEmitter.js +++ b/src/EventEmitter.js @@ -3,11 +3,11 @@ export default class EventEmitter { this._handlersByName = {}; } - emit(name, value) { + emit(name, ...values) { const handlers = this._handlersByName[name]; if (handlers) { for(const h of handlers) { - h(value); + h(...values); } } } @@ -15,6 +15,7 @@ export default class EventEmitter { on(name, callback) { let handlers = this._handlersByName[name]; if (!handlers) { + this.onFirstSubscriptionAdded(name); this._handlersByName[name] = handlers = new Set(); } handlers.add(callback); @@ -26,9 +27,14 @@ export default class EventEmitter { handlers.delete(callback); if (handlers.length === 0) { delete this._handlersByName[name]; + this.onLastSubscriptionRemoved(name); } } } + + onFirstSubscriptionAdded(name) {} + + onLastSubscriptionRemoved(name) {} } //#ifdef TESTS export function tests() { @@ -66,4 +72,4 @@ export function tests() { } }; } -//#endif \ No newline at end of file +//#endif diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 03a985ce..5339ffc9 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -1,11 +1,13 @@ import EventEmitter from "../../EventEmitter.js"; import RoomTileViewModel from "./roomlist/RoomTileViewModel.js"; import RoomViewModel from "./room/RoomViewModel.js"; +import SyncStatusViewModel from "./SyncStatusViewModel.js"; export default class SessionViewModel extends EventEmitter { - constructor(session) { + constructor(session, sync) { super(); this._session = session; + this._syncStatusViewModel = new SyncStatusViewModel(sync); this._currentRoomViewModel = null; const roomTileVMs = this._session.rooms.mapValues((room, emitUpdate) => { return new RoomTileViewModel({ @@ -17,6 +19,10 @@ export default class SessionViewModel extends EventEmitter { this._roomList = roomTileVMs.sortValues((a, b) => a.compare(b)); } + get syncStatusViewModel() { + return this._syncStatusViewModel; + } + get roomList() { return this._roomList; } @@ -29,7 +35,7 @@ export default class SessionViewModel extends EventEmitter { if (this._currentRoomViewModel) { this._currentRoomViewModel.disable(); } - this._currentRoomViewModel = new RoomViewModel(room); + this._currentRoomViewModel = new RoomViewModel(room, this._session.userId); this._currentRoomViewModel.enable(); this.emit("change", "currentRoom"); } diff --git a/src/domain/session/SyncStatusViewModel.js b/src/domain/session/SyncStatusViewModel.js new file mode 100644 index 00000000..ef227873 --- /dev/null +++ b/src/domain/session/SyncStatusViewModel.js @@ -0,0 +1,51 @@ +import EventEmitter from "../../EventEmitter.js"; + +export default class SyncStatusViewModel extends EventEmitter { + constructor(sync) { + super(); + this._sync = sync; + this._onStatus = this._onStatus.bind(this); + } + + _onStatus(status, err) { + if (status === "error") { + this._error = err; + } else if (status === "started") { + this._error = null; + } + this.emit("change"); + } + + onFirstSubscriptionAdded(name) { + if (name === "change") { + this._sync.on("status", this._onStatus); + } + } + + onLastSubscriptionRemoved(name) { + if (name === "change") { + this._sync.on("status", this._onStatus); + } + } + + trySync() { + this._sync.start(); + this.emit("change"); + } + + get status() { + if (!this.isSyncing) { + if (this._error) { + return `Error while syncing: ${this._error.message}`; + } else { + return "Sync stopped"; + } + } else { + return "Sync running"; + } + } + + get isSyncing() { + return this._sync.isSyncing; + } +} diff --git a/src/domain/session/avatar.js b/src/domain/session/avatar.js new file mode 100644 index 00000000..ba275d65 --- /dev/null +++ b/src/domain/session/avatar.js @@ -0,0 +1,4 @@ +export function avatarInitials(name) { + const words = name.split(" ").slice(0, 2); + return words.reduce((i, w) => i + w.charAt(0).toUpperCase(), ""); +} \ No newline at end of file diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 0e5f9b1c..84e21627 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -1,10 +1,12 @@ import EventEmitter from "../../../EventEmitter.js"; import TimelineViewModel from "./timeline/TimelineViewModel.js"; +import {avatarInitials} from "../avatar.js"; export default class RoomViewModel extends EventEmitter { - constructor(room) { + constructor(room, ownUserId) { super(); this._room = room; + this._ownUserId = ownUserId; this._timeline = null; this._timelineVM = null; this._onRoomChange = this._onRoomChange.bind(this); @@ -15,7 +17,7 @@ export default class RoomViewModel extends EventEmitter { this._room.on("change", this._onRoomChange); try { this._timeline = await this._room.openTimeline(); - this._timelineVM = new TimelineViewModel(this._timeline); + this._timelineVM = new TimelineViewModel(this._timeline, this._ownUserId); this.emit("change", "timelineViewModel"); } catch (err) { console.error(`room.openTimeline(): ${err.message}:\n${err.stack}`); @@ -49,6 +51,10 @@ export default class RoomViewModel extends EventEmitter { if (this._timelineError) { return `Something went wrong loading the timeline: ${this._timelineError.message}`; } - return null; + return ""; + } + + get avatarInitials() { + return avatarInitials(this._room.name); } } diff --git a/src/domain/session/room/timeline/TilesCollection.js b/src/domain/session/room/timeline/TilesCollection.js index 69268b41..818bfba3 100644 --- a/src/domain/session/room/timeline/TilesCollection.js +++ b/src/domain/session/room/timeline/TilesCollection.js @@ -95,8 +95,14 @@ export default class TilesCollection extends BaseObservableList { const newTile = this._tileCreator(entry, this._emitSpontanousUpdate); if (newTile) { - prevTile && prevTile.updateNextSibling(newTile); - nextTile && nextTile.updatePreviousSibling(newTile); + if (prevTile) { + prevTile.updateNextSibling(newTile); + newTile.updatePreviousSibling(prevTile); + } + if (nextTile) { + newTile.updateNextSibling(nextTile); + nextTile.updatePreviousSibling(newTile); + } this._tiles.splice(tileIdx, 0, newTile); this.emitAdd(tileIdx, newTile); } diff --git a/src/domain/session/room/timeline/TimelineViewModel.js b/src/domain/session/room/timeline/TimelineViewModel.js index 5c179673..0025c563 100644 --- a/src/domain/session/room/timeline/TimelineViewModel.js +++ b/src/domain/session/room/timeline/TimelineViewModel.js @@ -18,12 +18,12 @@ import TilesCollection from "./TilesCollection.js"; import tilesCreator from "./tilesCreator.js"; export default class TimelineViewModel { - constructor(timeline) { + constructor(timeline, ownUserId) { this._timeline = timeline; // once we support sending messages we could do // timeline.entries.concat(timeline.pendingEvents) // for an ObservableList that also contains local echos - this._tiles = new TilesCollection(timeline.entries, tilesCreator({timeline})); + this._tiles = new TilesCollection(timeline.entries, tilesCreator({timeline, ownUserId})); } // doesn't fill gaps, only loads stored entries/tiles diff --git a/src/domain/session/room/timeline/tiles/MessageTile.js b/src/domain/session/room/timeline/tiles/MessageTile.js index 8ada2310..f164c948 100644 --- a/src/domain/session/room/timeline/tiles/MessageTile.js +++ b/src/domain/session/room/timeline/tiles/MessageTile.js @@ -4,7 +4,9 @@ export default class MessageTile extends SimpleTile { constructor(options) { super(options); + this._isOwn = this._entry.event.sender === options.ownUserId; this._date = new Date(this._entry.event.origin_server_ts); + this._isContinuation = false; } get shape() { @@ -16,15 +18,32 @@ export default class MessageTile extends SimpleTile { } get date() { - return this._date.toLocaleDateString(); + return this._date.toLocaleDateString({}, {month: "numeric", day: "numeric"}); } get time() { - return this._date.toLocaleTimeString(); + return this._date.toLocaleTimeString({}, {hour: "numeric", minute: "2-digit"}); + } + + get isOwn() { + return this._isOwn; + } + + get isContinuation() { + return this._isContinuation; } _getContent() { const event = this._entry.event; return event && event.content; } + + updatePreviousSibling(prev) { + super.updatePreviousSibling(prev); + const isContinuation = prev && prev instanceof MessageTile && prev.sender === this.sender; + if (isContinuation !== this._isContinuation) { + this._isContinuation = isContinuation; + this.emitUpdate("isContinuation"); + } + } } diff --git a/src/domain/session/room/timeline/tiles/RoomMemberTile.js b/src/domain/session/room/timeline/tiles/RoomMemberTile.js index cc9796e9..3f0535fa 100644 --- a/src/domain/session/room/timeline/tiles/RoomMemberTile.js +++ b/src/domain/session/room/timeline/tiles/RoomMemberTile.js @@ -6,9 +6,22 @@ export default class RoomNameTile extends SimpleTile { return "announcement"; } - get label() { + get announcement() { const event = this._entry.event; const content = event.content; - return `${event.sender} changed membership to ${content.membership}`; + switch (content.membership) { + case "invite": return `${event.state_key} was invited to the room by ${event.sender}`; + case "join": return `${event.state_key} joined the room`; + case "leave": { + if (event.state_key === event.sender) { + return `${event.state_key} left the room`; + } else { + const reason = content.reason; + return `${event.state_key} was kicked from the room by ${event.sender}${reason ? `: ${reason}` : ""}`; + } + } + case "ban": return `${event.state_key} was banned from the room by ${event.sender}`; + default: return `${event.sender} membership changed to ${content.membership}`; + } } } diff --git a/src/domain/session/room/timeline/tiles/RoomNameTile.js b/src/domain/session/room/timeline/tiles/RoomNameTile.js index f415efe6..32cd5adf 100644 --- a/src/domain/session/room/timeline/tiles/RoomNameTile.js +++ b/src/domain/session/room/timeline/tiles/RoomNameTile.js @@ -6,9 +6,9 @@ export default class RoomNameTile extends SimpleTile { return "announcement"; } - get label() { + get announcement() { const event = this._entry.event; const content = event.content; - return `${event.sender} changed the room name to "${content.name}"` + return `${event.sender} named the room "${content.name}"` } } diff --git a/src/domain/session/room/timeline/tiles/TextTile.js b/src/domain/session/room/timeline/tiles/TextTile.js index 03485ea0..a13f24fb 100644 --- a/src/domain/session/room/timeline/tiles/TextTile.js +++ b/src/domain/session/room/timeline/tiles/TextTile.js @@ -1,14 +1,14 @@ import MessageTile from "./MessageTile.js"; export default class TextTile extends MessageTile { - get label() { + get text() { const content = this._getContent(); const body = content && content.body; const sender = this._entry.event.sender; if (this._entry.type === "m.emote") { return `* ${sender} ${body}`; } else { - return `${sender}: ${body}`; + return body; } } } diff --git a/src/domain/session/room/timeline/tilesCreator.js b/src/domain/session/room/timeline/tilesCreator.js index 60ce42ed..8af1f18f 100644 --- a/src/domain/session/room/timeline/tilesCreator.js +++ b/src/domain/session/room/timeline/tilesCreator.js @@ -5,9 +5,9 @@ import LocationTile from "./tiles/LocationTile.js"; import RoomNameTile from "./tiles/RoomNameTile.js"; import RoomMemberTile from "./tiles/RoomMemberTile.js"; -export default function ({timeline}) { +export default function ({timeline, ownUserId}) { return function tilesCreator(entry, emitUpdate) { - const options = {entry, emitUpdate}; + const options = {entry, emitUpdate, ownUserId}; if (entry.isGap) { return new GapTile(options, timeline); } else if (entry.event) { @@ -22,7 +22,8 @@ export default function ({timeline}) { case "m.emote": return new TextTile(options); case "m.image": - return new ImageTile(options); + return null; // not supported yet + // return new ImageTile(options); case "m.location": return new LocationTile(options); default: diff --git a/src/domain/session/roomlist/RoomTileViewModel.js b/src/domain/session/roomlist/RoomTileViewModel.js index 34c8ea86..110b88b2 100644 --- a/src/domain/session/roomlist/RoomTileViewModel.js +++ b/src/domain/session/roomlist/RoomTileViewModel.js @@ -1,3 +1,5 @@ +import {avatarInitials} from "../avatar.js"; + export default class RoomTileViewModel { // we use callbacks to parent VM instead of emit because // it would be annoying to keep track of subscriptions in @@ -21,4 +23,8 @@ export default class RoomTileViewModel { get name() { return this._room.name; } + + get avatarInitials() { + return avatarInitials(this._room.name); + } } diff --git a/src/main.js b/src/main.js index 6ecef32c..6500420c 100644 --- a/src/main.js +++ b/src/main.js @@ -2,7 +2,7 @@ import HomeServerApi from "./matrix/hs-api.js"; import Session from "./matrix/session.js"; import createIdbStorage from "./matrix/storage/idb/create.js"; import Sync from "./matrix/sync.js"; -import SessionView from "./ui/web/SessionView.js"; +import SessionView from "./ui/web/session/SessionView.js"; import SessionViewModel from "./domain/session/SessionViewModel.js"; const HOST = "localhost"; @@ -44,15 +44,14 @@ async function login(username, password, homeserver) { return storeSessionInfo(loginData); } -function showSession(container, session) { - const vm = new SessionViewModel(session); +function showSession(container, session, sync) { + const vm = new SessionViewModel(session, sync); const view = new SessionView(vm); - view.mount(); - container.appendChild(view.root()); + container.appendChild(view.mount()); } // eslint-disable-next-line no-unused-vars -export default async function main(label, button, container) { +export default async function main(container) { try { let sessionInfo = getSessionInfo(USER_ID); if (!sessionInfo) { @@ -67,26 +66,17 @@ export default async function main(label, button, container) { }}); await session.load(); console.log("session loaded"); + const sync = new Sync(hsApi, session, storage); const needsInitialSync = !session.syncToken; if (needsInitialSync) { console.log("session needs initial sync"); } else { - showSession(container, session); + showSession(container, session, sync); } - const sync = new Sync(hsApi, session, storage); await sync.start(); if (needsInitialSync) { - showSession(container, session); + showSession(container, session, sync); } - label.innerText = "sync running"; - button.addEventListener("click", () => sync.stop()); - sync.on("error", err => { - label.innerText = "sync error"; - console.error("sync error", err); - }); - sync.on("stopped", () => { - label.innerText = "sync stopped"; - }); } catch(err) { console.error(`${err.message}:\n${err.stack}`); } diff --git a/src/matrix/session.js b/src/matrix/session.js index 763d0265..a6b7b354 100644 --- a/src/matrix/session.js +++ b/src/matrix/session.js @@ -59,4 +59,8 @@ export default class Session { get syncToken() { return this._session.syncToken; } + + get userId() { + return this._sessionInfo.userId; + } } diff --git a/src/matrix/sync.js b/src/matrix/sync.js index 37425090..ad6074cc 100644 --- a/src/matrix/sync.js +++ b/src/matrix/sync.js @@ -32,12 +32,18 @@ export default class Sync extends EventEmitter { this._isSyncing = false; this._currentRequest = null; } + + get isSyncing() { + return this._isSyncing; + } + // returns when initial sync is done async start() { if (this._isSyncing) { return; } this._isSyncing = true; + this.emit("status", "started"); let syncToken = this._session.syncToken; // do initial sync if needed if (!syncToken) { @@ -56,12 +62,12 @@ export default class Sync extends EventEmitter { } catch (err) { this._isSyncing = false; if (!(err instanceof RequestAbortError)) { - console.warn("stopping sync because of error"); - this.emit("error", err); + console.error("stopping sync because of error", err.stack); + this.emit("status", "error", err); } } } - this.emit("stopped"); + this.emit("status", "stopped"); } async _syncRequest(syncToken, timeout) { diff --git a/src/ui/web/RoomTile.js b/src/ui/web/RoomTile.js deleted file mode 100644 index a68049ab..00000000 --- a/src/ui/web/RoomTile.js +++ /dev/null @@ -1,12 +0,0 @@ -import TemplateView from "./TemplateView.js"; - -export default class RoomTile extends TemplateView { - render(t) { - return t.li(vm => vm.name); - } - - // called from ListView - clicked() { - this.viewModel.open(); - } -} diff --git a/src/ui/web/RoomView.js b/src/ui/web/RoomView.js deleted file mode 100644 index 35523c9f..00000000 --- a/src/ui/web/RoomView.js +++ /dev/null @@ -1,55 +0,0 @@ -import TimelineTile from "./TimelineTile.js"; -import ListView from "./ListView.js"; -import {tag} from "./html.js"; -import GapView from "./timeline/GapView.js"; - -export default class RoomView { - constructor(viewModel) { - this._viewModel = viewModel; - this._root = null; - this._timelineList = null; - this._nameLabel = null; - this._onViewModelUpdate = this._onViewModelUpdate.bind(this); - } - - mount() { - this._viewModel.on("change", this._onViewModelUpdate); - this._nameLabel = tag.h2(null, this._viewModel.name); - this._errorLabel = tag.div({className: "RoomView_error"}); - - this._timelineList = new ListView({}, entry => { - return entry.shape === "gap" ? new GapView(entry) : new TimelineTile(entry); - }); - this._timelineList.mount(); - - this._root = tag.div({className: "RoomView"}, [ - this._nameLabel, - this._errorLabel, - this._timelineList.root() - ]); - - return this._root; - } - - unmount() { - this._timelineList.unmount(); - this._viewModel.off("change", this._onViewModelUpdate); - } - - root() { - return this._root; - } - - _onViewModelUpdate(prop) { - if (prop === "name") { - this._nameLabel.innerText = this._viewModel.name; - } - else if (prop === "timelineViewModel") { - this._timelineList.update({list: this._viewModel.timelineViewModel.tiles}); - } else if (prop === "error") { - this._errorLabel.innerText = this._viewModel.error; - } - } - - update() {} -} diff --git a/src/ui/web/SessionView.js b/src/ui/web/SessionView.js deleted file mode 100644 index ba92f80b..00000000 --- a/src/ui/web/SessionView.js +++ /dev/null @@ -1,67 +0,0 @@ -import ListView from "./ListView.js"; -import RoomTile from "./RoomTile.js"; -import RoomView from "./RoomView.js"; -import {tag} from "./html.js"; - -export default class SessionView { - constructor(viewModel) { - this._viewModel = viewModel; - this._roomList = null; - this._currentRoom = null; - this._root = null; - this._onViewModelChange = this._onViewModelChange.bind(this); - } - - root() { - return this._root; - } - - mount() { - this._viewModel.on("change", this._onViewModelChange); - - this._root = tag.div({className: "SessionView"}); - this._roomList = new ListView( - { - list: this._viewModel.roomList, - onItemClick: (roomTile, event) => roomTile.clicked(event) - }, - (room) => new RoomTile(room) - ); - this._roomList.mount(); - this._root.appendChild(this._roomList.root()); - - this._updateCurrentRoom(); - return this._root; - } - - unmount() { - this._roomList.unmount(); - if (this._room) { - this._room.unmount(); - } - - this._viewModel.off("change", this._onViewModelChange); - } - - _onViewModelChange(prop) { - if (prop === "currentRoom") { - this._updateCurrentRoom(); - } - } - - // changing viewModel not supported for now - update() {} - - _updateCurrentRoom() { - if (this._currentRoom) { - this._currentRoom.root().remove(); - this._currentRoom.unmount(); - this._currentRoom = null; - } - if (this._viewModel.currentRoom) { - this._currentRoom = new RoomView(this._viewModel.currentRoom); - this._currentRoom.mount(); - this.root().appendChild(this._currentRoom.root()); - } - } -} diff --git a/src/ui/web/css/avatar.css b/src/ui/web/css/avatar.css new file mode 100644 index 00000000..b780ef23 --- /dev/null +++ b/src/ui/web/css/avatar.css @@ -0,0 +1,27 @@ +.avatar { + --avatar-size: 32px; + width: var(--avatar-size); + height: var(--avatar-size); + border-radius: 100px; + overflow: hidden; + flex-shrink: 0; + -moz-user-select: none; + -webkit-user-select: none; + -ms-user-select: none; + user-select: none; + line-height: var(--avatar-size); + font-size: calc(var(--avatar-size) * 0.6); + text-align: center; + letter-spacing: calc(var(--avatar-size) * -0.05); + background: white; + color: black; +} + +.avatar.large { + --avatar-size: 40px; +} + +.avatar img { + width: 100%; + height: 100%; +} diff --git a/src/ui/web/css/layout.css b/src/ui/web/css/layout.css new file mode 100644 index 00000000..55f9b4f9 --- /dev/null +++ b/src/ui/web/css/layout.css @@ -0,0 +1,56 @@ +html { + height: 100%; +} +body { + margin: 0; +} + +.SessionView { + display: flex; + flex-direction: column; + height: 100vh; +} + +.SessionView > .main { + flex: 1; + display: flex; + min-height: 0; + min-width: 0; + width: 100vw; +} + +/* mobile layout */ +@media screen and (max-width: 800px) { + .back { display: block !important; } + .RoomView, .RoomPlaceholderView { display: none; } + .room-shown .RoomView { display: unset; } + .room-shown .LeftPanel { display: none; } + .right-shown .TimelinePanel { display: none; } +} + +.LeftPanel { + flex: 0 0 300px; + min-width: 0; +} + +.RoomPlaceholderView, .RoomView { + flex: 1 0 0; + min-width: 0; +} + +.RoomView { + min-width: 0; + display: flex; +} + + +.TimelinePanel { + flex: 3; + min-height: 0; + display: flex; + flex-direction: column; +} + +.RoomHeader { + display: flex; +} diff --git a/src/ui/web/css/left-panel.css b/src/ui/web/css/left-panel.css new file mode 100644 index 00000000..b3a16457 --- /dev/null +++ b/src/ui/web/css/left-panel.css @@ -0,0 +1,47 @@ + +.LeftPanel { + background: #333; + color: white; + overflow-y: auto; +} + +.LeftPanel ul { + list-style: none; + padding: 0; + margin: 0; +} + +.LeftPanel li { + margin: 5px; + padding: 10px; + display: flex; + align-items: center; +} + +.LeftPanel li { + border-bottom: 1px #555 solid; +} + +.LeftPanel li:last-child { + border-bottom: none; +} + +.LeftPanel li > * { + margin-right: 10px; +} + +.LeftPanel div.description { + margin: 0; + flex: 1 1 0; + min-width: 0; +} + +.LeftPanel .description > * { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.LeftPanel .description .last-message { + font-size: 0.8em; +} diff --git a/src/ui/web/css/main.css b/src/ui/web/css/main.css new file mode 100644 index 00000000..18e1ba6c --- /dev/null +++ b/src/ui/web/css/main.css @@ -0,0 +1,28 @@ +@import url('layout.css'); +@import url('left-panel.css'); +@import url('room.css'); +@import url('timeline.css'); +@import url('avatar.css'); + +body { + margin: 0; + font-family: sans-serif; + background-color: black; + color: white; +} + +.SyncStatusBar { + background-color: #555; + display: none; +} + +.SyncStatusBar_shown { + display: unset; +} + +.RoomPlaceholderView { + display: flex; + align-items: center; + justify-content: center; + flex-direction: row; +} diff --git a/src/ui/web/css/room.css b/src/ui/web/css/room.css new file mode 100644 index 00000000..f90ee8c8 --- /dev/null +++ b/src/ui/web/css/room.css @@ -0,0 +1,65 @@ + +.RoomHeader { + padding: 10px; + background-color: #333; +} + +.RoomHeader *:last-child { + margin-right: 0 !important; +} + +.RoomHeader > * { + margin-right: 10px !important; +} + +.RoomHeader button { + width: 40px; + height: 40px; + display: none; + font-size: 1.5em; + padding: 0; + display: block; + background: white; + border: none; + font-weight: bolder; + line-height: 40px; +} + +.RoomHeader button.back { + display: none; +} + +.RoomHeader .topic { + font-size: 0.8em; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.back::before { + content: "☰"; +} + +.more::before { + content: "⋮"; +} + +.RoomHeader { + align-items: center; +} + +.RoomHeader .description { + flex: 1 1 auto; + min-width: 0; +} + +.RoomHeader h2 { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + margin: 0; +} + +.RoomView_error { + color: red; +} diff --git a/src/ui/web/css/timeline.css b/src/ui/web/css/timeline.css new file mode 100644 index 00000000..aea0be49 --- /dev/null +++ b/src/ui/web/css/timeline.css @@ -0,0 +1,74 @@ + +.TimelinePanel ul { + flex: 1; + overflow-y: auto; + list-style: none; + padding: 0; + margin: 0; +} + +.TimelinePanel li { +} + +.message-container { + flex: 0 1 auto; + max-width: 80%; + padding: 5px 10px; + margin: 5px 10px; + background: blue; +} + +.message-container .sender { + margin: 5px 0; + font-size: 0.9em; + font-weight: bold; +} + +.TextMessageView { + display: flex; + min-width: 0; +} + +.TextMessageView.own .message-container { + margin-left: auto; +} + +.TextMessageView .message-container time { + float: right; + padding: 2px 0 0px 20px; + font-size: 0.9em; + color: lightblue; +} + +.message-container time { + font-size: 0.9em; + color: lightblue; +} + +.own time { + color: lightgreen; +} + +.own .message-container { + background-color: darkgreen; +} + +.message-container p { + margin: 5px 0; +} + +.AnnouncementView { + margin: 5px 0; + padding: 5px 10%; + display: flex; + align-items: center; +} + +.AnnouncementView > div { + margin: 0 auto; + padding: 10px 20px; + background-color: #333; + font-size: 0.9em; + color: #CCC; + text-align: center; +} diff --git a/src/ui/web/ListView.js b/src/ui/web/general/ListView.js similarity index 82% rename from src/ui/web/ListView.js rename to src/ui/web/general/ListView.js index f12651a3..bab72006 100644 --- a/src/ui/web/ListView.js +++ b/src/ui/web/general/ListView.js @@ -19,9 +19,10 @@ function insertAt(parentNode, idx, childNode) { } export default class ListView { - constructor({list, onItemClick}, childCreator) { + constructor({list, onItemClick, className}, childCreator) { this._onItemClick = onItemClick; this._list = list; + this._className = className; this._root = null; this._subscription = null; this._childCreator = childCreator; @@ -47,7 +48,11 @@ export default class ListView { } mount() { - this._root = tag.ul({className: "ListView"}); + const attr = {}; + if (this._className) { + attr.className = this._className; + } + this._root = tag.ul(attr); this._loadList(); if (this._onItemClick) { this._root.addEventListener("click", this._onClick); @@ -95,25 +100,37 @@ export default class ListView { } onAdd(idx, value) { + this.onBeforeListChanged(); const child = this._childCreator(value); this._childInstances.splice(idx, 0, child); insertAt(this._root, idx, child.mount()); + this.onListChanged(); } onRemove(idx, _value) { + this.onBeforeListChanged(); const [child] = this._childInstances.splice(idx, 1); child.root().remove(); child.unmount(); + this.onListChanged(); } onMove(fromIdx, toIdx, value) { + this.onBeforeListChanged(); const [child] = this._childInstances.splice(fromIdx, 1); this._childInstances.splice(toIdx, 0, child); child.root().remove(); insertAt(this._root, toIdx, child.root()); + this.onListChanged(); } onUpdate(i, value, params) { - this._childInstances[i].update(value, params); + if (this._childInstances) { + const instance = this._childInstances[i]; + instance && instance.update(value, params); + } } + + onBeforeListChanged() {} + onListChanged() {} } diff --git a/src/ui/web/general/SwitchView.js b/src/ui/web/general/SwitchView.js new file mode 100644 index 00000000..7789cb1e --- /dev/null +++ b/src/ui/web/general/SwitchView.js @@ -0,0 +1,36 @@ +export default class SwitchView { + constructor(defaultView) { + this._childView = defaultView; + } + + mount() { + return this._childView.mount(); + } + + unmount() { + return this._childView.unmount(); + } + + root() { + return this._childView.root(); + } + + update() { + return this._childView.update(); + } + + switch(newView) { + const oldRoot = this.root(); + this._childView.unmount(); + this._childView = newView; + const newRoot = this._childView.mount(); + const parent = oldRoot.parentElement; + if (parent) { + parent.replaceChild(newRoot, oldRoot); + } + } + + get childView() { + return this._childView; + } +} diff --git a/src/ui/web/Template.js b/src/ui/web/general/Template.js similarity index 88% rename from src/ui/web/Template.js rename to src/ui/web/general/Template.js index de47a6f0..b8c566c2 100644 --- a/src/ui/web/Template.js +++ b/src/ui/web/general/Template.js @@ -1,17 +1,13 @@ -import { setAttribute, text, TAG_NAMES } from "./html.js"; +import { setAttribute, text, isChildren, classNames, TAG_NAMES } from "./html.js"; -function classNames(obj, value) { - return Object.entries(obj).reduce((cn, [name, enabled]) => { - if (typeof enabled === "function") { - enabled = enabled(value); +function objHasFns(obj) { + for(const value of Object.values(obj)) { + if (typeof value === "function") { + return true; } - if (enabled) { - return (cn.length ? " " : "") + name; - } else { - return cn; - } - }, ""); + } + return false; } /** Bindable template. Renders once, and allows bindings for given nodes. If you need @@ -130,14 +126,9 @@ export default class Template { } el(name, attributes, children) { - if (attributes) { - // valid attributes is only object that is not a DOM node - // anything else (string, fn, array, dom node) is presumed - // to be children with no attributes passed - if (typeof attributes !== "object" || !!attributes.nodeType || Array.isArray(attributes)) { - children = attributes; - attributes = null; - } + if (attributes && isChildren(attributes)) { + children = attributes; + attributes = null; } const node = document.createElement(name); @@ -157,7 +148,11 @@ export default class Template { const isFn = typeof value === "function"; // binding for className as object of className => enabled if (key === "className" && typeof value === "object" && value !== null) { - this._addClassNamesBinding(node, value); + if (objHasFns(value)) { + this._addClassNamesBinding(node, value); + } else { + setAttribute(node, key, classNames(value)); + } } else if (key.startsWith("on") && key.length > 2 && isFn) { const eventName = key.substr(2, 1).toLowerCase() + key.substr(3); const handler = value; diff --git a/src/ui/web/TemplateView.js b/src/ui/web/general/TemplateView.js similarity index 54% rename from src/ui/web/TemplateView.js rename to src/ui/web/general/TemplateView.js index 96d1a500..da7f4938 100644 --- a/src/ui/web/TemplateView.js +++ b/src/ui/web/general/TemplateView.js @@ -1,8 +1,9 @@ import Template from "./Template.js"; export default class TemplateView { - constructor(value) { - this.viewModel = value; + constructor(vm, bindToChangeEvent) { + this.viewModel = vm; + this._changeEventHandler = bindToChangeEvent ? this.update.bind(this, this.viewModel) : null; this._template = null; } @@ -11,6 +12,9 @@ export default class TemplateView { } mount() { + if (this._changeEventHandler) { + this.viewModel.on("change", this._changeEventHandler); + } this._template = new Template(this.viewModel, (t, value) => this.render(t, value)); return this.root(); } @@ -20,11 +24,14 @@ export default class TemplateView { } unmount() { + if (this._changeEventHandler) { + this.viewModel.off("change", this._changeEventHandler); + } this._template.dispose(); this._template = null; } - update(value) { + update(value, prop) { this._template.update(value); } } diff --git a/src/ui/web/html.js b/src/ui/web/general/html.js similarity index 52% rename from src/ui/web/html.js rename to src/ui/web/general/html.js index 604090f2..e316d3c8 100644 --- a/src/ui/web/html.js +++ b/src/ui/web/general/html.js @@ -1,5 +1,23 @@ // DOM helper functions +export function isChildren(children) { + // children should be an not-object (that's the attributes), or a domnode, or an array + return typeof children !== "object" || !!children.nodeType || Array.isArray(children); +} + +export function classNames(obj, value) { + return Object.entries(obj).reduce((cn, [name, enabled]) => { + if (typeof enabled === "function") { + enabled = enabled(value); + } + if (enabled) { + return cn + (cn.length ? " " : "") + name; + } else { + return cn; + } + }, ""); +} + export function setAttribute(el, name, value) { if (name === "className") { name = "class"; @@ -14,13 +32,23 @@ export function setAttribute(el, name, value) { } } -export function el(elementName, attrs, children) { +export function el(elementName, attributes, children) { + if (attributes && isChildren(attributes)) { + children = attributes; + attributes = null; + } + const e = document.createElement(elementName); - if (typeof attrs === "object" && attrs !== null) { - for (let [name, value] of Object.entries(attrs)) { + + if (attributes) { + for (let [name, value] of Object.entries(attributes)) { + if (name === "className" && typeof value === "object" && value !== null) { + value = classNames(value); + } setAttribute(e, name, value); } } + if (children) { if (!Array.isArray(children)) { children = [children]; @@ -42,7 +70,7 @@ export function text(str) { export const TAG_NAMES = [ "ol", "ul", "li", "div", "h1", "h2", "h3", "h4", "h5", "h6", "p", "strong", "em", "span", "img", "section", "main", "article", "aside", - "pre", "button"]; + "pre", "button", "time"]; export const tag = {}; diff --git a/src/ui/web/login/LoginView.js b/src/ui/web/login/LoginView.js new file mode 100644 index 00000000..a9ccf667 --- /dev/null +++ b/src/ui/web/login/LoginView.js @@ -0,0 +1,19 @@ +import TemplateView from "./general/TemplateView.js"; + +export default class LoginView extends TemplateView { + render(t, vm) { + const username = t.input({type: "text", placeholder: vm.usernamePlaceholder}); + const password = t.input({type: "password", placeholder: vm.usernamePlaceholder}); + const homeserver = t.input({type: "text", placeholder: vm.hsPlaceholder, value: vm.defaultHS}); + return t.div({className: "login form"}, [ + t.if(vm => vm.error, t => t.div({className: "error"}, vm => vm.error)), + t.div(username), + t.div(password), + t.div(homeserver), + t.div(t.button({ + onClick: () => vm.login(username.value, password.value, homeserver.value), + disabled: vm => vm.isBusy + }, "Log In")) + ]); + } +} diff --git a/src/ui/web/session/RoomPlaceholderView.js b/src/ui/web/session/RoomPlaceholderView.js new file mode 100644 index 00000000..d4cb7e0e --- /dev/null +++ b/src/ui/web/session/RoomPlaceholderView.js @@ -0,0 +1,19 @@ +import {tag} from "../general/html.js"; + +export default class RoomPlaceholderView { + constructor() { + this._root = null; + } + + mount() { + this._root = tag.div({className: "RoomPlaceholderView"}, tag.h2("Choose a room on the left side.")); + return this._root; + } + + root() { + return this._root; + } + + unmount() {} + update() {} +} diff --git a/src/ui/web/session/RoomTile.js b/src/ui/web/session/RoomTile.js new file mode 100644 index 00000000..59c76cd8 --- /dev/null +++ b/src/ui/web/session/RoomTile.js @@ -0,0 +1,15 @@ +import TemplateView from "../general/TemplateView.js"; + +export default class RoomTile extends TemplateView { + render(t) { + return t.li([ + t.div({className: "avatar medium"}, vm => vm.avatarInitials), + t.div({className: "description"}, t.div({className: "name"}, vm => vm.name)) + ]); + } + + // called from ListView + clicked() { + this.viewModel.open(); + } +} diff --git a/src/ui/web/session/SessionView.js b/src/ui/web/session/SessionView.js new file mode 100644 index 00000000..dc52c84d --- /dev/null +++ b/src/ui/web/session/SessionView.js @@ -0,0 +1,62 @@ +import ListView from "../general/ListView.js"; +import RoomTile from "./RoomTile.js"; +import RoomView from "./room/RoomView.js"; +import SwitchView from "../general/SwitchView.js"; +import RoomPlaceholderView from "./RoomPlaceholderView.js"; +import SyncStatusBar from "./SyncStatusBar.js"; +import {tag} from "../general/html.js"; + +export default class SessionView { + constructor(viewModel) { + this._viewModel = viewModel; + this._middleSwitcher = null; + this._roomList = null; + this._currentRoom = null; + this._root = null; + this._onViewModelChange = this._onViewModelChange.bind(this); + } + + root() { + return this._root; + } + + mount() { + this._viewModel.on("change", this._onViewModelChange); + this._syncStatusBar = new SyncStatusBar(this._viewModel.syncStatusViewModel); + this._roomList = new ListView( + { + className: "RoomList", + list: this._viewModel.roomList, + onItemClick: (roomTile, event) => roomTile.clicked(event) + }, + (room) => new RoomTile(room) + ); + this._middleSwitcher = new SwitchView(new RoomPlaceholderView()); + + this._root = tag.div({className: "SessionView"}, [ + this._syncStatusBar.mount(), + tag.div({className: "main"}, [ + tag.div({className: "LeftPanel"}, this._roomList.mount()), + this._middleSwitcher.mount() + ]) + ]); + + return this._root; + } + + unmount() { + this._roomList.unmount(); + this._middleSwitcher.unmount(); + this._viewModel.off("change", this._onViewModelChange); + } + + _onViewModelChange(prop) { + if (prop === "currentRoom") { + this._root.classList.add("room-shown"); + this._middleSwitcher.switch(new RoomView(this._viewModel.currentRoom)); + } + } + + // changing viewModel not supported for now + update() {} +} diff --git a/src/ui/web/session/SyncStatusBar.js b/src/ui/web/session/SyncStatusBar.js new file mode 100644 index 00000000..08ab9955 --- /dev/null +++ b/src/ui/web/session/SyncStatusBar.js @@ -0,0 +1,17 @@ +import TemplateView from "../general/TemplateView.js"; + +export default class SyncStatusBar extends TemplateView { + constructor(vm) { + super(vm, true); + } + + render(t, vm) { + return t.div({className: { + "SyncStatusBar": true, + "SyncStatusBar_shown": true, + }}, [ + vm => vm.status, + t.if(vm => !vm.isSyncing, t => t.button({onClick: () => vm.trySync()}, "Try syncing")) + ]); + } +} diff --git a/src/ui/web/session/room/RoomView.js b/src/ui/web/session/room/RoomView.js new file mode 100644 index 00000000..88b46dbc --- /dev/null +++ b/src/ui/web/session/room/RoomView.js @@ -0,0 +1,47 @@ +import TemplateView from "../../general/TemplateView.js"; +import TimelineList from "./TimelineList.js"; + +export default class RoomView extends TemplateView { + constructor(viewModel) { + super(viewModel, true); + this._timelineList = null; + this._checkScroll = this._checkScroll.bind(this); + } + + render(t) { + return t.div({className: "RoomView"}, [ + t.div({className: "TimelinePanel"}, [ + t.div({className: "RoomHeader"}, [ + t.button({className: "back"}), + t.div({className: "avatar large"}, vm => vm.avatarInitials), + t.div({className: "room-description"}, [ + t.h2(vm => vm.name), + ]), + ]), + t.div({className: "RoomView_error"}, vm => vm.error), + this._timelineList.mount() + ]) + ]); + } + + mount() { + this._timelineList = new TimelineList(); + return super.mount(); + } + + unmount() { + this._timelineList.unmount(); + super.unmount(); + } + + update(value, prop) { + super.update(value, prop); + if (prop === "timelineViewModel") { + this._timelineList.update({list: this.viewModel.timelineViewModel.tiles}); + } + } + + _checkScroll() { + // const list = this._timelineList.root(); + } +} diff --git a/src/ui/web/session/room/TimelineList.js b/src/ui/web/session/room/TimelineList.js new file mode 100644 index 00000000..0045a0fc --- /dev/null +++ b/src/ui/web/session/room/TimelineList.js @@ -0,0 +1,37 @@ +import ListView from "../../general/ListView.js"; +import GapView from "./timeline/GapView.js"; +import TextMessageView from "./timeline/TextMessageView.js"; +import AnnouncementView from "./timeline/AnnouncementView.js"; + +export default class TimelineList extends ListView { + constructor(options = {}) { + options.className = "Timeline"; + super(options, entry => { + switch (entry.shape) { + case "gap": return new GapView(entry); + case "announcement": return new AnnouncementView(entry); + case "message":return new TextMessageView(entry); + } + }); + this._atBottom = false; + } + + _loadList() { + super._loadList(); + const root = this.root(); + root.scrollTop = root.scrollHeight; + } + + onBeforeListChanged() { + const root = this.root(); + const fromBottom = root.scrollHeight - root.scrollTop - root.clientHeight; + this._atBottom = fromBottom < 1; + } + + onListChanged() { + if (this._atBottom) { + const root = this.root(); + root.scrollTop = root.scrollHeight; + } + } +} diff --git a/src/ui/web/session/room/timeline/AnnouncementView.js b/src/ui/web/session/room/timeline/AnnouncementView.js new file mode 100644 index 00000000..fff0d081 --- /dev/null +++ b/src/ui/web/session/room/timeline/AnnouncementView.js @@ -0,0 +1,7 @@ +import TemplateView from "../../../general/TemplateView.js"; + +export default class AnnouncementView extends TemplateView { + render(t) { + return t.li({className: "AnnouncementView"}, t.div(vm => vm.announcement)); + } +} diff --git a/src/ui/web/timeline/GapView.js b/src/ui/web/session/room/timeline/GapView.js similarity index 60% rename from src/ui/web/timeline/GapView.js rename to src/ui/web/session/room/timeline/GapView.js index 258cc167..62cde6a6 100644 --- a/src/ui/web/timeline/GapView.js +++ b/src/ui/web/session/room/timeline/GapView.js @@ -1,14 +1,17 @@ -import TemplateView from "../TemplateView.js"; +import TemplateView from "../../../general/TemplateView.js"; export default class GapView extends TemplateView { render(t, vm) { const className = { - gap: true, + GapView: true, isLoading: vm => vm.isLoading }; const label = (vm.isUp ? "🠝" : "🠟") + " fill gap"; //no binding return t.li({className}, [ - t.button({onClick: () => this.viewModel.fill(), disabled: vm => vm.isLoading}, label), + t.button({ + onClick: () => this.viewModel.fill(), + disabled: vm => vm.isLoading + }, label), t.if(vm => vm.error, t => t.strong(vm => vm.error)) ]); } diff --git a/src/ui/web/session/room/timeline/TextMessageView.js b/src/ui/web/session/room/timeline/TextMessageView.js new file mode 100644 index 00000000..3d489c5c --- /dev/null +++ b/src/ui/web/session/room/timeline/TextMessageView.js @@ -0,0 +1,14 @@ +import TemplateView from "../../../general/TemplateView.js"; + +export default class TextMessageView extends TemplateView { + render(t, vm) { + // no bindings ... should this be a template view? + return t.li( + {className: {"TextMessageView": true, own: vm.isOwn}}, + t.div({className: "message-container"}, [ + t.div({className: "sender"}, vm => vm.isContinuation ? "" : vm.sender), + t.p([vm.text, t.time(vm.date + " " + vm.time)]), + ]) + ); + } +} diff --git a/src/ui/web/TimelineTile.js b/src/ui/web/session/room/timeline/TimelineTile.js similarity index 59% rename from src/ui/web/TimelineTile.js rename to src/ui/web/session/room/timeline/TimelineTile.js index 8ee411d2..4e87f182 100644 --- a/src/ui/web/TimelineTile.js +++ b/src/ui/web/session/room/timeline/TimelineTile.js @@ -1,4 +1,4 @@ -import {tag} from "./html.js"; +import {tag} from "../../../general/html.js"; export default class TimelineTile { constructor(tileVM) { @@ -24,10 +24,10 @@ export default class TimelineTile { function renderTile(tile) { switch (tile.shape) { case "message": - return tag.li(null, [tag.strong(null, tile.internalId+" "), tile.label]); + return tag.li([tag.strong(tile.internalId+" "), tile.label]); case "announcement": - return tag.li(null, [tag.strong(null, tile.internalId+" "), tile.label]); + return tag.li([tag.strong(tile.internalId+" "), tile.announcement]); default: - return tag.li(null, [tag.strong(null, tile.internalId+" "), "unknown tile shape: " + tile.shape]); + return tag.li([tag.strong(tile.internalId+" "), "unknown tile shape: " + tile.shape]); } }