apply css from prototype, other small changes, keep scroll at bottom

This commit is contained in:
Bruno Windels 2019-06-16 15:21:20 +02:00
parent d72a7102b2
commit 4a657b279d
24 changed files with 415 additions and 100 deletions

View File

@ -2,54 +2,7 @@
<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>
<script type="module"> <script type="module">

View 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(), "");
}

View File

@ -1,5 +1,6 @@
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, ownUserId) { constructor(room, ownUserId) {
@ -50,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);
} }
} }

View File

@ -21,7 +21,7 @@ export default class MessageTile extends SimpleTile {
} }
get time() { get time() {
return this._date.toLocaleTimeString(); return this._date.toLocaleTimeString({}, {hour: "numeric", minute: "2-digit"});
} }
get isOwn() { get isOwn() {

View File

@ -9,6 +9,19 @@ export default class RoomNameTile extends SimpleTile {
get announcement() { 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}`;
}
} }
} }

View File

@ -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;
} }
} }
} }

View File

@ -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);
}
} }

27
src/ui/web/css/avatar.css Normal file
View 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
View 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;
}

View 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;
}

21
src/ui/web/css/main.css Normal file
View File

@ -0,0 +1,21 @@
@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;
}

65
src/ui/web/css/room.css Normal file
View 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;
}

View 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;
}

View File

@ -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,34 @@ 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); this._childInstances[i].update(value, params);
} }
onBeforeListChanged() {}
onListChanged() {}
} }

View File

@ -31,7 +31,7 @@ export default class TemplateView {
this._template = null; this._template = null;
} }
update(value) { update(value, prop) {
this._template.update(value); this._template.update(value);
} }
} }

View File

@ -70,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 = {};

View File

@ -6,7 +6,7 @@ export default class RoomPlaceholderView {
} }
mount() { mount() {
this._root = tag.div(tag.h2("Choose a room on the left side.")); this._root = tag.div({className: "RoomPlaceholderView"}, tag.h2("Choose a room on the left side."));
return this._root; return this._root;
} }

View File

@ -2,7 +2,10 @@ import TemplateView from "../general/TemplateView.js";
export default class RoomTile extends TemplateView { export default class RoomTile extends TemplateView {
render(t) { render(t) {
return t.li(vm => vm.name); 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 // called from ListView

View File

@ -25,6 +25,7 @@ export default class SessionView {
this._syncStatusBar = new SyncStatusBar(this._viewModel.syncStatusViewModel); this._syncStatusBar = new SyncStatusBar(this._viewModel.syncStatusViewModel);
this._roomList = new ListView( this._roomList = new ListView(
{ {
className: "RoomList",
list: this._viewModel.roomList, list: this._viewModel.roomList,
onItemClick: (roomTile, event) => roomTile.clicked(event) onItemClick: (roomTile, event) => roomTile.clicked(event)
}, },
@ -35,7 +36,7 @@ export default class SessionView {
this._root = tag.div({className: "SessionView"}, [ this._root = tag.div({className: "SessionView"}, [
this._syncStatusBar.mount(), this._syncStatusBar.mount(),
tag.div({className: "main"}, [ tag.div({className: "main"}, [
this._roomList.mount(), tag.div({className: "LeftPanel"}, this._roomList.mount()),
this._middleSwitcher.mount() this._middleSwitcher.mount()
]) ])
]); ]);
@ -51,6 +52,7 @@ export default class SessionView {
_onViewModelChange(prop) { _onViewModelChange(prop) {
if (prop === "currentRoom") { if (prop === "currentRoom") {
this._root.classList.add("room-shown");
this._middleSwitcher.switch(new RoomView(this._viewModel.currentRoom)); this._middleSwitcher.switch(new RoomView(this._viewModel.currentRoom));
} }
} }

View File

@ -8,6 +8,7 @@ export default class SyncStatusBar extends TemplateView {
render(t, vm) { render(t, vm) {
return t.div({className: { return t.div({className: {
"SyncStatusBar": true, "SyncStatusBar": true,
"SyncStatusBar_shown": true,
}}, [ }}, [
vm => vm.status, vm => vm.status,
t.if(vm => !vm.isSyncing, t => t.button({onClick: () => vm.trySync()}, "Try syncing")) t.if(vm => !vm.isSyncing, t => t.button({onClick: () => vm.trySync()}, "Try syncing"))

View File

@ -1,55 +1,47 @@
import TimelineTile from "./timeline/TimelineTile.js"; import TemplateView from "../../general/TemplateView.js";
import ListView from "../../general/ListView.js"; import TimelineList from "./TimelineList.js";
import {tag} from "../../general/html.js";
import GapView from "./timeline/GapView.js";
export default class RoomView { export default class RoomView extends TemplateView {
constructor(viewModel) { constructor(viewModel) {
this._viewModel = viewModel; super(viewModel, true);
this._root = null;
this._timelineList = null; this._timelineList = null;
this._nameLabel = null; this._checkScroll = this._checkScroll.bind(this);
this._onViewModelUpdate = this._onViewModelUpdate.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() { mount() {
this._viewModel.on("change", this._onViewModelUpdate); this._timelineList = new TimelineList();
this._nameLabel = tag.h2(null, this._viewModel.name); return super.mount();
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() { unmount() {
this._timelineList.unmount(); this._timelineList.unmount();
this._viewModel.off("change", this._onViewModelUpdate); super.unmount();
} }
root() { update(value, prop) {
return this._root; super.update(value, prop);
} if (prop === "timelineViewModel") {
this._timelineList.update({list: this.viewModel.timelineViewModel.tiles});
_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() {} _checkScroll() {
// const list = this._timelineList.root();
}
} }

View File

@ -0,0 +1,31 @@
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;
}
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;
}
}
}

View File

@ -2,6 +2,6 @@ import TemplateView from "../../../general/TemplateView.js";
export default class AnnouncementView extends TemplateView { export default class AnnouncementView extends TemplateView {
render(t) { render(t) {
return t.li({className: "AnnouncementView"}, vm => vm.announcement); return t.li({className: "AnnouncementView"}, t.div(vm => vm.announcement));
} }
} }

View File

@ -2,6 +2,7 @@ import TemplateView from "../../../general/TemplateView.js";
export default class TextMessageView extends TemplateView { export default class TextMessageView extends TemplateView {
render(t, vm) { render(t, vm) {
// no bindings ... should this be a template view?
return t.li( return t.li(
{className: {"TextMessageView": true, own: vm.isOwn}}, {className: {"TextMessageView": true, own: vm.isOwn}},
t.div({className: "message-container"}, [ t.div({className: "message-container"}, [