mirror of
https://github.com/vector-im/hydrogen-web.git
synced 2024-12-23 11:35:04 +01:00
Merge pull request #3 from bwindels/bwindels/fancy-ui
Implement UI from prototype
This commit is contained in:
commit
cf39a57aa1
@ -1,9 +1,9 @@
|
|||||||
view hierarchy:
|
view hierarchy:
|
||||||
```
|
```
|
||||||
BrawlView
|
BrawlView
|
||||||
SyncStatusBar
|
|
||||||
SwitchView
|
SwitchView
|
||||||
SessionView
|
SessionView
|
||||||
|
SyncStatusBar
|
||||||
ListView(left-panel)
|
ListView(left-panel)
|
||||||
SwitchView
|
SwitchView
|
||||||
RoomPlaceholderView
|
RoomPlaceholderView
|
||||||
@ -12,4 +12,15 @@ view hierarchy:
|
|||||||
ListView(timeline)
|
ListView(timeline)
|
||||||
ComposerView
|
ComposerView
|
||||||
RightPanel
|
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
|
||||||
|
60
index.html
60
index.html
@ -2,68 +2,12 @@
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<style type="text/css">
|
<link rel="stylesheet" type="text/css" href="src/ui/web/css/main.css">
|
||||||
#container {
|
|
||||||
height: 80vh;
|
|
||||||
border: 1px solid black;
|
|
||||||
font-family: sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.SessionView {
|
|
||||||
display: flex;
|
|
||||||
height: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ListView {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.SessionView > .ListView {
|
|
||||||
height: 100%;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
border-right: 1px solid black;
|
|
||||||
list-style: none;
|
|
||||||
overflow-y: scroll;
|
|
||||||
}
|
|
||||||
|
|
||||||
.SessionView > .ListView > li {
|
|
||||||
padding: 10px;
|
|
||||||
border-bottom: 1px solid grey;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.SessionView > .RoomView {
|
|
||||||
padding: 10px;
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.SessionView > .RoomView > .ListView {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: scroll;
|
|
||||||
}
|
|
||||||
|
|
||||||
.RoomView_error {
|
|
||||||
color: red;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<p id="syncstatus"></p>
|
|
||||||
<div><button id="stopsync">stop syncing</button></div>
|
|
||||||
<div id="container"></div>
|
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import main from "./src/main.js";
|
import main from "./src/main.js";
|
||||||
const label = document.getElementById("syncstatus");
|
main(document.body);
|
||||||
const button = document.getElementById("stopsync");
|
|
||||||
const container = document.getElementById("container");
|
|
||||||
|
|
||||||
//import("./src/main.js").then(main => {
|
|
||||||
main(label, button, container);
|
|
||||||
//});
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@ -3,11 +3,11 @@ export default class EventEmitter {
|
|||||||
this._handlersByName = {};
|
this._handlersByName = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
emit(name, value) {
|
emit(name, ...values) {
|
||||||
const handlers = this._handlersByName[name];
|
const handlers = this._handlersByName[name];
|
||||||
if (handlers) {
|
if (handlers) {
|
||||||
for(const h of handlers) {
|
for(const h of handlers) {
|
||||||
h(value);
|
h(...values);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -15,6 +15,7 @@ export default class EventEmitter {
|
|||||||
on(name, callback) {
|
on(name, callback) {
|
||||||
let handlers = this._handlersByName[name];
|
let handlers = this._handlersByName[name];
|
||||||
if (!handlers) {
|
if (!handlers) {
|
||||||
|
this.onFirstSubscriptionAdded(name);
|
||||||
this._handlersByName[name] = handlers = new Set();
|
this._handlersByName[name] = handlers = new Set();
|
||||||
}
|
}
|
||||||
handlers.add(callback);
|
handlers.add(callback);
|
||||||
@ -26,9 +27,14 @@ export default class EventEmitter {
|
|||||||
handlers.delete(callback);
|
handlers.delete(callback);
|
||||||
if (handlers.length === 0) {
|
if (handlers.length === 0) {
|
||||||
delete this._handlersByName[name];
|
delete this._handlersByName[name];
|
||||||
|
this.onLastSubscriptionRemoved(name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onFirstSubscriptionAdded(name) {}
|
||||||
|
|
||||||
|
onLastSubscriptionRemoved(name) {}
|
||||||
}
|
}
|
||||||
//#ifdef TESTS
|
//#ifdef TESTS
|
||||||
export function tests() {
|
export function tests() {
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
import EventEmitter from "../../EventEmitter.js";
|
import EventEmitter from "../../EventEmitter.js";
|
||||||
import RoomTileViewModel from "./roomlist/RoomTileViewModel.js";
|
import RoomTileViewModel from "./roomlist/RoomTileViewModel.js";
|
||||||
import RoomViewModel from "./room/RoomViewModel.js";
|
import RoomViewModel from "./room/RoomViewModel.js";
|
||||||
|
import SyncStatusViewModel from "./SyncStatusViewModel.js";
|
||||||
|
|
||||||
export default class SessionViewModel extends EventEmitter {
|
export default class SessionViewModel extends EventEmitter {
|
||||||
constructor(session) {
|
constructor(session, sync) {
|
||||||
super();
|
super();
|
||||||
this._session = session;
|
this._session = session;
|
||||||
|
this._syncStatusViewModel = new SyncStatusViewModel(sync);
|
||||||
this._currentRoomViewModel = null;
|
this._currentRoomViewModel = null;
|
||||||
const roomTileVMs = this._session.rooms.mapValues((room, emitUpdate) => {
|
const roomTileVMs = this._session.rooms.mapValues((room, emitUpdate) => {
|
||||||
return new RoomTileViewModel({
|
return new RoomTileViewModel({
|
||||||
@ -17,6 +19,10 @@ export default class SessionViewModel extends EventEmitter {
|
|||||||
this._roomList = roomTileVMs.sortValues((a, b) => a.compare(b));
|
this._roomList = roomTileVMs.sortValues((a, b) => a.compare(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get syncStatusViewModel() {
|
||||||
|
return this._syncStatusViewModel;
|
||||||
|
}
|
||||||
|
|
||||||
get roomList() {
|
get roomList() {
|
||||||
return this._roomList;
|
return this._roomList;
|
||||||
}
|
}
|
||||||
@ -29,7 +35,7 @@ export default class SessionViewModel extends EventEmitter {
|
|||||||
if (this._currentRoomViewModel) {
|
if (this._currentRoomViewModel) {
|
||||||
this._currentRoomViewModel.disable();
|
this._currentRoomViewModel.disable();
|
||||||
}
|
}
|
||||||
this._currentRoomViewModel = new RoomViewModel(room);
|
this._currentRoomViewModel = new RoomViewModel(room, this._session.userId);
|
||||||
this._currentRoomViewModel.enable();
|
this._currentRoomViewModel.enable();
|
||||||
this.emit("change", "currentRoom");
|
this.emit("change", "currentRoom");
|
||||||
}
|
}
|
||||||
|
51
src/domain/session/SyncStatusViewModel.js
Normal file
51
src/domain/session/SyncStatusViewModel.js
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
4
src/domain/session/avatar.js
Normal file
4
src/domain/session/avatar.js
Normal file
@ -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(), "");
|
||||||
|
}
|
@ -1,10 +1,12 @@
|
|||||||
import EventEmitter from "../../../EventEmitter.js";
|
import EventEmitter from "../../../EventEmitter.js";
|
||||||
import TimelineViewModel from "./timeline/TimelineViewModel.js";
|
import TimelineViewModel from "./timeline/TimelineViewModel.js";
|
||||||
|
import {avatarInitials} from "../avatar.js";
|
||||||
|
|
||||||
export default class RoomViewModel extends EventEmitter {
|
export default class RoomViewModel extends EventEmitter {
|
||||||
constructor(room) {
|
constructor(room, ownUserId) {
|
||||||
super();
|
super();
|
||||||
this._room = room;
|
this._room = room;
|
||||||
|
this._ownUserId = ownUserId;
|
||||||
this._timeline = null;
|
this._timeline = null;
|
||||||
this._timelineVM = null;
|
this._timelineVM = null;
|
||||||
this._onRoomChange = this._onRoomChange.bind(this);
|
this._onRoomChange = this._onRoomChange.bind(this);
|
||||||
@ -15,7 +17,7 @@ export default class RoomViewModel extends EventEmitter {
|
|||||||
this._room.on("change", this._onRoomChange);
|
this._room.on("change", this._onRoomChange);
|
||||||
try {
|
try {
|
||||||
this._timeline = await this._room.openTimeline();
|
this._timeline = await this._room.openTimeline();
|
||||||
this._timelineVM = new TimelineViewModel(this._timeline);
|
this._timelineVM = new TimelineViewModel(this._timeline, this._ownUserId);
|
||||||
this.emit("change", "timelineViewModel");
|
this.emit("change", "timelineViewModel");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`room.openTimeline(): ${err.message}:\n${err.stack}`);
|
console.error(`room.openTimeline(): ${err.message}:\n${err.stack}`);
|
||||||
@ -49,6 +51,10 @@ export default class RoomViewModel extends EventEmitter {
|
|||||||
if (this._timelineError) {
|
if (this._timelineError) {
|
||||||
return `Something went wrong loading the timeline: ${this._timelineError.message}`;
|
return `Something went wrong loading the timeline: ${this._timelineError.message}`;
|
||||||
}
|
}
|
||||||
return null;
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
get avatarInitials() {
|
||||||
|
return avatarInitials(this._room.name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -95,8 +95,14 @@ export default class TilesCollection extends BaseObservableList {
|
|||||||
|
|
||||||
const newTile = this._tileCreator(entry, this._emitSpontanousUpdate);
|
const newTile = this._tileCreator(entry, this._emitSpontanousUpdate);
|
||||||
if (newTile) {
|
if (newTile) {
|
||||||
prevTile && prevTile.updateNextSibling(newTile);
|
if (prevTile) {
|
||||||
nextTile && nextTile.updatePreviousSibling(newTile);
|
prevTile.updateNextSibling(newTile);
|
||||||
|
newTile.updatePreviousSibling(prevTile);
|
||||||
|
}
|
||||||
|
if (nextTile) {
|
||||||
|
newTile.updateNextSibling(nextTile);
|
||||||
|
nextTile.updatePreviousSibling(newTile);
|
||||||
|
}
|
||||||
this._tiles.splice(tileIdx, 0, newTile);
|
this._tiles.splice(tileIdx, 0, newTile);
|
||||||
this.emitAdd(tileIdx, newTile);
|
this.emitAdd(tileIdx, newTile);
|
||||||
}
|
}
|
||||||
|
@ -18,12 +18,12 @@ import TilesCollection from "./TilesCollection.js";
|
|||||||
import tilesCreator from "./tilesCreator.js";
|
import tilesCreator from "./tilesCreator.js";
|
||||||
|
|
||||||
export default class TimelineViewModel {
|
export default class TimelineViewModel {
|
||||||
constructor(timeline) {
|
constructor(timeline, ownUserId) {
|
||||||
this._timeline = timeline;
|
this._timeline = timeline;
|
||||||
// once we support sending messages we could do
|
// once we support sending messages we could do
|
||||||
// timeline.entries.concat(timeline.pendingEvents)
|
// timeline.entries.concat(timeline.pendingEvents)
|
||||||
// for an ObservableList that also contains local echos
|
// 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
|
// doesn't fill gaps, only loads stored entries/tiles
|
||||||
|
@ -4,7 +4,9 @@ export default class MessageTile extends SimpleTile {
|
|||||||
|
|
||||||
constructor(options) {
|
constructor(options) {
|
||||||
super(options);
|
super(options);
|
||||||
|
this._isOwn = this._entry.event.sender === options.ownUserId;
|
||||||
this._date = new Date(this._entry.event.origin_server_ts);
|
this._date = new Date(this._entry.event.origin_server_ts);
|
||||||
|
this._isContinuation = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
get shape() {
|
get shape() {
|
||||||
@ -16,15 +18,32 @@ export default class MessageTile extends SimpleTile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get date() {
|
get date() {
|
||||||
return this._date.toLocaleDateString();
|
return this._date.toLocaleDateString({}, {month: "numeric", day: "numeric"});
|
||||||
}
|
}
|
||||||
|
|
||||||
get time() {
|
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() {
|
_getContent() {
|
||||||
const event = this._entry.event;
|
const event = this._entry.event;
|
||||||
return event && event.content;
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -6,9 +6,22 @@ export default class RoomNameTile extends SimpleTile {
|
|||||||
return "announcement";
|
return "announcement";
|
||||||
}
|
}
|
||||||
|
|
||||||
get label() {
|
get announcement() {
|
||||||
const event = this._entry.event;
|
const event = this._entry.event;
|
||||||
const content = event.content;
|
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}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -6,9 +6,9 @@ export default class RoomNameTile extends SimpleTile {
|
|||||||
return "announcement";
|
return "announcement";
|
||||||
}
|
}
|
||||||
|
|
||||||
get label() {
|
get announcement() {
|
||||||
const event = this._entry.event;
|
const event = this._entry.event;
|
||||||
const content = event.content;
|
const content = event.content;
|
||||||
return `${event.sender} changed the room name to "${content.name}"`
|
return `${event.sender} named the room "${content.name}"`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
import MessageTile from "./MessageTile.js";
|
import MessageTile from "./MessageTile.js";
|
||||||
|
|
||||||
export default class TextTile extends MessageTile {
|
export default class TextTile extends MessageTile {
|
||||||
get label() {
|
get text() {
|
||||||
const content = this._getContent();
|
const content = this._getContent();
|
||||||
const body = content && content.body;
|
const body = content && content.body;
|
||||||
const sender = this._entry.event.sender;
|
const sender = this._entry.event.sender;
|
||||||
if (this._entry.type === "m.emote") {
|
if (this._entry.type === "m.emote") {
|
||||||
return `* ${sender} ${body}`;
|
return `* ${sender} ${body}`;
|
||||||
} else {
|
} else {
|
||||||
return `${sender}: ${body}`;
|
return body;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,9 +5,9 @@ import LocationTile from "./tiles/LocationTile.js";
|
|||||||
import RoomNameTile from "./tiles/RoomNameTile.js";
|
import RoomNameTile from "./tiles/RoomNameTile.js";
|
||||||
import RoomMemberTile from "./tiles/RoomMemberTile.js";
|
import RoomMemberTile from "./tiles/RoomMemberTile.js";
|
||||||
|
|
||||||
export default function ({timeline}) {
|
export default function ({timeline, ownUserId}) {
|
||||||
return function tilesCreator(entry, emitUpdate) {
|
return function tilesCreator(entry, emitUpdate) {
|
||||||
const options = {entry, emitUpdate};
|
const options = {entry, emitUpdate, ownUserId};
|
||||||
if (entry.isGap) {
|
if (entry.isGap) {
|
||||||
return new GapTile(options, timeline);
|
return new GapTile(options, timeline);
|
||||||
} else if (entry.event) {
|
} else if (entry.event) {
|
||||||
@ -22,7 +22,8 @@ export default function ({timeline}) {
|
|||||||
case "m.emote":
|
case "m.emote":
|
||||||
return new TextTile(options);
|
return new TextTile(options);
|
||||||
case "m.image":
|
case "m.image":
|
||||||
return new ImageTile(options);
|
return null; // not supported yet
|
||||||
|
// return new ImageTile(options);
|
||||||
case "m.location":
|
case "m.location":
|
||||||
return new LocationTile(options);
|
return new LocationTile(options);
|
||||||
default:
|
default:
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import {avatarInitials} from "../avatar.js";
|
||||||
|
|
||||||
export default class RoomTileViewModel {
|
export default class RoomTileViewModel {
|
||||||
// we use callbacks to parent VM instead of emit because
|
// we use callbacks to parent VM instead of emit because
|
||||||
// it would be annoying to keep track of subscriptions in
|
// it would be annoying to keep track of subscriptions in
|
||||||
@ -21,4 +23,8 @@ export default class RoomTileViewModel {
|
|||||||
get name() {
|
get name() {
|
||||||
return this._room.name;
|
return this._room.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get avatarInitials() {
|
||||||
|
return avatarInitials(this._room.name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
26
src/main.js
26
src/main.js
@ -2,7 +2,7 @@ import HomeServerApi from "./matrix/hs-api.js";
|
|||||||
import Session from "./matrix/session.js";
|
import Session from "./matrix/session.js";
|
||||||
import createIdbStorage from "./matrix/storage/idb/create.js";
|
import createIdbStorage from "./matrix/storage/idb/create.js";
|
||||||
import Sync from "./matrix/sync.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";
|
import SessionViewModel from "./domain/session/SessionViewModel.js";
|
||||||
|
|
||||||
const HOST = "localhost";
|
const HOST = "localhost";
|
||||||
@ -44,15 +44,14 @@ async function login(username, password, homeserver) {
|
|||||||
return storeSessionInfo(loginData);
|
return storeSessionInfo(loginData);
|
||||||
}
|
}
|
||||||
|
|
||||||
function showSession(container, session) {
|
function showSession(container, session, sync) {
|
||||||
const vm = new SessionViewModel(session);
|
const vm = new SessionViewModel(session, sync);
|
||||||
const view = new SessionView(vm);
|
const view = new SessionView(vm);
|
||||||
view.mount();
|
container.appendChild(view.mount());
|
||||||
container.appendChild(view.root());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
// eslint-disable-next-line no-unused-vars
|
||||||
export default async function main(label, button, container) {
|
export default async function main(container) {
|
||||||
try {
|
try {
|
||||||
let sessionInfo = getSessionInfo(USER_ID);
|
let sessionInfo = getSessionInfo(USER_ID);
|
||||||
if (!sessionInfo) {
|
if (!sessionInfo) {
|
||||||
@ -67,26 +66,17 @@ export default async function main(label, button, container) {
|
|||||||
}});
|
}});
|
||||||
await session.load();
|
await session.load();
|
||||||
console.log("session loaded");
|
console.log("session loaded");
|
||||||
|
const sync = new Sync(hsApi, session, storage);
|
||||||
const needsInitialSync = !session.syncToken;
|
const needsInitialSync = !session.syncToken;
|
||||||
if (needsInitialSync) {
|
if (needsInitialSync) {
|
||||||
console.log("session needs initial sync");
|
console.log("session needs initial sync");
|
||||||
} else {
|
} else {
|
||||||
showSession(container, session);
|
showSession(container, session, sync);
|
||||||
}
|
}
|
||||||
const sync = new Sync(hsApi, session, storage);
|
|
||||||
await sync.start();
|
await sync.start();
|
||||||
if (needsInitialSync) {
|
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) {
|
} catch(err) {
|
||||||
console.error(`${err.message}:\n${err.stack}`);
|
console.error(`${err.message}:\n${err.stack}`);
|
||||||
}
|
}
|
||||||
|
@ -59,4 +59,8 @@ export default class Session {
|
|||||||
get syncToken() {
|
get syncToken() {
|
||||||
return this._session.syncToken;
|
return this._session.syncToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get userId() {
|
||||||
|
return this._sessionInfo.userId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -32,12 +32,18 @@ export default class Sync extends EventEmitter {
|
|||||||
this._isSyncing = false;
|
this._isSyncing = false;
|
||||||
this._currentRequest = null;
|
this._currentRequest = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get isSyncing() {
|
||||||
|
return this._isSyncing;
|
||||||
|
}
|
||||||
|
|
||||||
// returns when initial sync is done
|
// returns when initial sync is done
|
||||||
async start() {
|
async start() {
|
||||||
if (this._isSyncing) {
|
if (this._isSyncing) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this._isSyncing = true;
|
this._isSyncing = true;
|
||||||
|
this.emit("status", "started");
|
||||||
let syncToken = this._session.syncToken;
|
let syncToken = this._session.syncToken;
|
||||||
// do initial sync if needed
|
// do initial sync if needed
|
||||||
if (!syncToken) {
|
if (!syncToken) {
|
||||||
@ -56,12 +62,12 @@ export default class Sync extends EventEmitter {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
this._isSyncing = false;
|
this._isSyncing = false;
|
||||||
if (!(err instanceof RequestAbortError)) {
|
if (!(err instanceof RequestAbortError)) {
|
||||||
console.warn("stopping sync because of error");
|
console.error("stopping sync because of error", err.stack);
|
||||||
this.emit("error", err);
|
this.emit("status", "error", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.emit("stopped");
|
this.emit("status", "stopped");
|
||||||
}
|
}
|
||||||
|
|
||||||
async _syncRequest(syncToken, timeout) {
|
async _syncRequest(syncToken, timeout) {
|
||||||
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
@ -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() {}
|
|
||||||
}
|
|
@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
27
src/ui/web/css/avatar.css
Normal file
27
src/ui/web/css/avatar.css
Normal file
@ -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%;
|
||||||
|
}
|
56
src/ui/web/css/layout.css
Normal file
56
src/ui/web/css/layout.css
Normal file
@ -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;
|
||||||
|
}
|
47
src/ui/web/css/left-panel.css
Normal file
47
src/ui/web/css/left-panel.css
Normal file
@ -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;
|
||||||
|
}
|
28
src/ui/web/css/main.css
Normal file
28
src/ui/web/css/main.css
Normal file
@ -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;
|
||||||
|
}
|
65
src/ui/web/css/room.css
Normal file
65
src/ui/web/css/room.css
Normal file
@ -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;
|
||||||
|
}
|
74
src/ui/web/css/timeline.css
Normal file
74
src/ui/web/css/timeline.css
Normal file
@ -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;
|
||||||
|
}
|
@ -19,9 +19,10 @@ function insertAt(parentNode, idx, childNode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default class ListView {
|
export default class ListView {
|
||||||
constructor({list, onItemClick}, childCreator) {
|
constructor({list, onItemClick, className}, childCreator) {
|
||||||
this._onItemClick = onItemClick;
|
this._onItemClick = onItemClick;
|
||||||
this._list = list;
|
this._list = list;
|
||||||
|
this._className = className;
|
||||||
this._root = null;
|
this._root = null;
|
||||||
this._subscription = null;
|
this._subscription = null;
|
||||||
this._childCreator = childCreator;
|
this._childCreator = childCreator;
|
||||||
@ -47,7 +48,11 @@ export default class ListView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mount() {
|
mount() {
|
||||||
this._root = tag.ul({className: "ListView"});
|
const attr = {};
|
||||||
|
if (this._className) {
|
||||||
|
attr.className = this._className;
|
||||||
|
}
|
||||||
|
this._root = tag.ul(attr);
|
||||||
this._loadList();
|
this._loadList();
|
||||||
if (this._onItemClick) {
|
if (this._onItemClick) {
|
||||||
this._root.addEventListener("click", this._onClick);
|
this._root.addEventListener("click", this._onClick);
|
||||||
@ -95,25 +100,37 @@ export default class ListView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onAdd(idx, value) {
|
onAdd(idx, value) {
|
||||||
|
this.onBeforeListChanged();
|
||||||
const child = this._childCreator(value);
|
const child = this._childCreator(value);
|
||||||
this._childInstances.splice(idx, 0, child);
|
this._childInstances.splice(idx, 0, child);
|
||||||
insertAt(this._root, idx, child.mount());
|
insertAt(this._root, idx, child.mount());
|
||||||
|
this.onListChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
onRemove(idx, _value) {
|
onRemove(idx, _value) {
|
||||||
|
this.onBeforeListChanged();
|
||||||
const [child] = this._childInstances.splice(idx, 1);
|
const [child] = this._childInstances.splice(idx, 1);
|
||||||
child.root().remove();
|
child.root().remove();
|
||||||
child.unmount();
|
child.unmount();
|
||||||
|
this.onListChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
onMove(fromIdx, toIdx, value) {
|
onMove(fromIdx, toIdx, value) {
|
||||||
|
this.onBeforeListChanged();
|
||||||
const [child] = this._childInstances.splice(fromIdx, 1);
|
const [child] = this._childInstances.splice(fromIdx, 1);
|
||||||
this._childInstances.splice(toIdx, 0, child);
|
this._childInstances.splice(toIdx, 0, child);
|
||||||
child.root().remove();
|
child.root().remove();
|
||||||
insertAt(this._root, toIdx, child.root());
|
insertAt(this._root, toIdx, child.root());
|
||||||
|
this.onListChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
onUpdate(i, value, params) {
|
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() {}
|
||||||
|
}
|
36
src/ui/web/general/SwitchView.js
Normal file
36
src/ui/web/general/SwitchView.js
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -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) {
|
function objHasFns(obj) {
|
||||||
return Object.entries(obj).reduce((cn, [name, enabled]) => {
|
for(const value of Object.values(obj)) {
|
||||||
if (typeof enabled === "function") {
|
if (typeof value === "function") {
|
||||||
enabled = enabled(value);
|
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
|
Bindable template. Renders once, and allows bindings for given nodes. If you need
|
||||||
@ -130,15 +126,10 @@ export default class Template {
|
|||||||
}
|
}
|
||||||
|
|
||||||
el(name, attributes, children) {
|
el(name, attributes, children) {
|
||||||
if (attributes) {
|
if (attributes && isChildren(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;
|
children = attributes;
|
||||||
attributes = null;
|
attributes = null;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const node = document.createElement(name);
|
const node = document.createElement(name);
|
||||||
|
|
||||||
@ -157,7 +148,11 @@ export default class Template {
|
|||||||
const isFn = typeof value === "function";
|
const isFn = typeof value === "function";
|
||||||
// binding for className as object of className => enabled
|
// binding for className as object of className => enabled
|
||||||
if (key === "className" && typeof value === "object" && value !== null) {
|
if (key === "className" && typeof value === "object" && value !== null) {
|
||||||
|
if (objHasFns(value)) {
|
||||||
this._addClassNamesBinding(node, value);
|
this._addClassNamesBinding(node, value);
|
||||||
|
} else {
|
||||||
|
setAttribute(node, key, classNames(value));
|
||||||
|
}
|
||||||
} else if (key.startsWith("on") && key.length > 2 && isFn) {
|
} else if (key.startsWith("on") && key.length > 2 && isFn) {
|
||||||
const eventName = key.substr(2, 1).toLowerCase() + key.substr(3);
|
const eventName = key.substr(2, 1).toLowerCase() + key.substr(3);
|
||||||
const handler = value;
|
const handler = value;
|
@ -1,8 +1,9 @@
|
|||||||
import Template from "./Template.js";
|
import Template from "./Template.js";
|
||||||
|
|
||||||
export default class TemplateView {
|
export default class TemplateView {
|
||||||
constructor(value) {
|
constructor(vm, bindToChangeEvent) {
|
||||||
this.viewModel = value;
|
this.viewModel = vm;
|
||||||
|
this._changeEventHandler = bindToChangeEvent ? this.update.bind(this, this.viewModel) : null;
|
||||||
this._template = null;
|
this._template = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -11,6 +12,9 @@ export default class TemplateView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mount() {
|
mount() {
|
||||||
|
if (this._changeEventHandler) {
|
||||||
|
this.viewModel.on("change", this._changeEventHandler);
|
||||||
|
}
|
||||||
this._template = new Template(this.viewModel, (t, value) => this.render(t, value));
|
this._template = new Template(this.viewModel, (t, value) => this.render(t, value));
|
||||||
return this.root();
|
return this.root();
|
||||||
}
|
}
|
||||||
@ -20,11 +24,14 @@ export default class TemplateView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
unmount() {
|
unmount() {
|
||||||
|
if (this._changeEventHandler) {
|
||||||
|
this.viewModel.off("change", this._changeEventHandler);
|
||||||
|
}
|
||||||
this._template.dispose();
|
this._template.dispose();
|
||||||
this._template = null;
|
this._template = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
update(value) {
|
update(value, prop) {
|
||||||
this._template.update(value);
|
this._template.update(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,5 +1,23 @@
|
|||||||
// DOM helper functions
|
// 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) {
|
export function setAttribute(el, name, value) {
|
||||||
if (name === "className") {
|
if (name === "className") {
|
||||||
name = "class";
|
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);
|
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);
|
setAttribute(e, name, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (children) {
|
if (children) {
|
||||||
if (!Array.isArray(children)) {
|
if (!Array.isArray(children)) {
|
||||||
children = [children];
|
children = [children];
|
||||||
@ -42,7 +70,7 @@ export function text(str) {
|
|||||||
export const TAG_NAMES = [
|
export const TAG_NAMES = [
|
||||||
"ol", "ul", "li", "div", "h1", "h2", "h3", "h4", "h5", "h6",
|
"ol", "ul", "li", "div", "h1", "h2", "h3", "h4", "h5", "h6",
|
||||||
"p", "strong", "em", "span", "img", "section", "main", "article", "aside",
|
"p", "strong", "em", "span", "img", "section", "main", "article", "aside",
|
||||||
"pre", "button"];
|
"pre", "button", "time"];
|
||||||
|
|
||||||
export const tag = {};
|
export const tag = {};
|
||||||
|
|
19
src/ui/web/login/LoginView.js
Normal file
19
src/ui/web/login/LoginView.js
Normal file
@ -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"))
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
19
src/ui/web/session/RoomPlaceholderView.js
Normal file
19
src/ui/web/session/RoomPlaceholderView.js
Normal file
@ -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() {}
|
||||||
|
}
|
15
src/ui/web/session/RoomTile.js
Normal file
15
src/ui/web/session/RoomTile.js
Normal file
@ -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();
|
||||||
|
}
|
||||||
|
}
|
62
src/ui/web/session/SessionView.js
Normal file
62
src/ui/web/session/SessionView.js
Normal file
@ -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() {}
|
||||||
|
}
|
17
src/ui/web/session/SyncStatusBar.js
Normal file
17
src/ui/web/session/SyncStatusBar.js
Normal file
@ -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"))
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
47
src/ui/web/session/room/RoomView.js
Normal file
47
src/ui/web/session/room/RoomView.js
Normal file
@ -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();
|
||||||
|
}
|
||||||
|
}
|
37
src/ui/web/session/room/TimelineList.js
Normal file
37
src/ui/web/session/room/TimelineList.js
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
7
src/ui/web/session/room/timeline/AnnouncementView.js
Normal file
7
src/ui/web/session/room/timeline/AnnouncementView.js
Normal file
@ -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));
|
||||||
|
}
|
||||||
|
}
|
@ -1,14 +1,17 @@
|
|||||||
import TemplateView from "../TemplateView.js";
|
import TemplateView from "../../../general/TemplateView.js";
|
||||||
|
|
||||||
export default class GapView extends TemplateView {
|
export default class GapView extends TemplateView {
|
||||||
render(t, vm) {
|
render(t, vm) {
|
||||||
const className = {
|
const className = {
|
||||||
gap: true,
|
GapView: true,
|
||||||
isLoading: vm => vm.isLoading
|
isLoading: vm => vm.isLoading
|
||||||
};
|
};
|
||||||
const label = (vm.isUp ? "🠝" : "🠟") + " fill gap"; //no binding
|
const label = (vm.isUp ? "🠝" : "🠟") + " fill gap"; //no binding
|
||||||
return t.li({className}, [
|
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))
|
t.if(vm => vm.error, t => t.strong(vm => vm.error))
|
||||||
]);
|
]);
|
||||||
}
|
}
|
14
src/ui/web/session/room/timeline/TextMessageView.js
Normal file
14
src/ui/web/session/room/timeline/TextMessageView.js
Normal file
@ -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)]),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
import {tag} from "./html.js";
|
import {tag} from "../../../general/html.js";
|
||||||
|
|
||||||
export default class TimelineTile {
|
export default class TimelineTile {
|
||||||
constructor(tileVM) {
|
constructor(tileVM) {
|
||||||
@ -24,10 +24,10 @@ export default class TimelineTile {
|
|||||||
function renderTile(tile) {
|
function renderTile(tile) {
|
||||||
switch (tile.shape) {
|
switch (tile.shape) {
|
||||||
case "message":
|
case "message":
|
||||||
return tag.li(null, [tag.strong(null, tile.internalId+" "), tile.label]);
|
return tag.li([tag.strong(tile.internalId+" "), tile.label]);
|
||||||
case "announcement":
|
case "announcement":
|
||||||
return tag.li(null, [tag.strong(null, tile.internalId+" "), tile.label]);
|
return tag.li([tag.strong(tile.internalId+" "), tile.announcement]);
|
||||||
default:
|
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]);
|
||||||
}
|
}
|
||||||
}
|
}
|
Loading…
Reference in New Issue
Block a user