Merge pull request #178 from vector-im/bwindels/lightbox

Lightbox for picture messages
This commit is contained in:
Bruno Windels 2020-11-02 11:23:10 +00:00 committed by GitHub
commit a3ec01385b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 638 additions and 104 deletions

View file

@ -36,6 +36,8 @@ function allowsChild(parent, child) {
case "rooms": case "rooms":
// downside of the approach: both of these will control which tile is selected // downside of the approach: both of these will control which tile is selected
return type === "room" || type === "empty-grid-tile"; return type === "room" || type === "empty-grid-tile";
case "room":
return type === "lightbox";
default: default:
return false; return false;
} }

View file

@ -17,6 +17,7 @@ limitations under the License.
import {LeftPanelViewModel} from "./leftpanel/LeftPanelViewModel.js"; import {LeftPanelViewModel} from "./leftpanel/LeftPanelViewModel.js";
import {RoomViewModel} from "./room/RoomViewModel.js"; import {RoomViewModel} from "./room/RoomViewModel.js";
import {LightboxViewModel} from "./room/LightboxViewModel.js";
import {SessionStatusViewModel} from "./SessionStatusViewModel.js"; import {SessionStatusViewModel} from "./SessionStatusViewModel.js";
import {RoomGridViewModel} from "./RoomGridViewModel.js"; import {RoomGridViewModel} from "./RoomGridViewModel.js";
import {SettingsViewModel} from "./settings/SettingsViewModel.js"; import {SettingsViewModel} from "./settings/SettingsViewModel.js";
@ -67,6 +68,12 @@ export class SessionViewModel extends ViewModel {
this._updateSettings(settingsOpen); this._updateSettings(settingsOpen);
})); }));
this._updateSettings(settings.get()); this._updateSettings(settings.get());
const lightbox = this.navigation.observe("lightbox");
this.track(lightbox.subscribe(eventId => {
this._updateLightbox(eventId);
}));
this._updateLightbox(lightbox.get());
} }
get id() { get id() {
@ -194,4 +201,20 @@ export class SessionViewModel extends ViewModel {
} }
this.emitChange("activeSection"); this.emitChange("activeSection");
} }
_updateLightbox(eventId) {
if (this._lightboxViewModel) {
this._lightboxViewModel = this.disposeTracked(this._lightboxViewModel);
}
if (eventId) {
const roomId = this.navigation.path.get("room").value;
const room = this._sessionContainer.session.rooms.get(roomId);
this._lightboxViewModel = this.track(new LightboxViewModel(this.childOptions({eventId, room})));
}
this.emitChange("lightboxViewModel");
}
get lightboxViewModel() {
return this._lightboxViewModel;
}
} }

View file

@ -0,0 +1,97 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {ViewModel} from "../../ViewModel.js";
export class LightboxViewModel extends ViewModel {
constructor(options) {
super(options);
this._eventId = options.eventId;
this._unencryptedImageUrl = null;
this._decryptedImage = null;
this._closeUrl = this.urlCreator.urlUntilSegment("room");
this._eventEntry = null;
this._date = null;
this._subscribeToEvent(options.room, options.eventId);
}
_subscribeToEvent(room, eventId) {
const eventObservable = room.observeEvent(eventId);
this.track(eventObservable.subscribe(eventEntry => {
this._loadEvent(room, eventEntry);
}));
this._loadEvent(room, eventObservable.get());
}
async _loadEvent(room, eventEntry) {
if (!eventEntry) {
return;
}
const {mediaRepository} = room;
this._eventEntry = eventEntry;
const {content} = this._eventEntry;
this._date = this._eventEntry.timestamp ? new Date(this._eventEntry.timestamp) : null;
if (content.url) {
this._unencryptedImageUrl = mediaRepository.mxcUrl(content.url);
this.emitChange("imageUrl");
} else if (content.file) {
this._decryptedImage = this.track(await mediaRepository.downloadEncryptedFile(content.file));
this.emitChange("imageUrl");
}
}
get imageWidth() {
return this._eventEntry?.content?.info?.w;
}
get imageHeight() {
return this._eventEntry?.content?.info?.h;
}
get name() {
return this._eventEntry?.content?.body;
}
get sender() {
return this._eventEntry?.displayName;
}
get imageUrl() {
if (this._decryptedImage) {
return this._decryptedImage.url;
} else if (this._unencryptedImageUrl) {
return this._unencryptedImageUrl;
} else {
return "";
}
}
get date() {
return this._date && this._date.toLocaleDateString({}, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
}
get time() {
return this._date && this._date.toLocaleTimeString({}, {hour: "numeric", minute: "2-digit"});
}
get closeUrl() {
return this._closeUrl;
}
close() {
this.platform.history.pushUrl(this.closeUrl);
}
}

View file

@ -43,7 +43,7 @@ export class TimelineViewModel extends ViewModel {
// 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({room, ownUserId, platform: this.platform})); this._tiles = new TilesCollection(timeline.entries, tilesCreator(this.childOptions({room, ownUserId})));
} }
async load() { async load() {

View file

@ -18,22 +18,25 @@ import {SimpleTile} from "./SimpleTile.js";
import {UpdateAction} from "../UpdateAction.js"; import {UpdateAction} from "../UpdateAction.js";
export class GapTile extends SimpleTile { export class GapTile extends SimpleTile {
constructor(options, timeline) { constructor(options) {
super(options); super(options);
this._timeline = timeline;
this._loading = false; this._loading = false;
this._error = null; this._error = null;
} }
get _room() {
return this.getOption("room");
}
async fill() { async fill() {
// prevent doing this twice // prevent doing this twice
if (!this._loading) { if (!this._loading) {
this._loading = true; this._loading = true;
this.emitChange("isLoading"); this.emitChange("isLoading");
try { try {
await this._timeline.fillGap(this._entry, 10); await this._room.fillGap(this._entry, 10);
} catch (err) { } catch (err) {
console.error(`timeline.fillGap(): ${err.message}:\n${err.stack}`); console.error(`room.fillGap(): ${err.message}:\n${err.stack}`);
this._error = err; this._error = err;
this.emitChange("error"); this.emitChange("error");
// rethrow so caller of this method // rethrow so caller of this method

View file

@ -27,14 +27,20 @@ export class ImageTile extends MessageTile {
this._decryptedImage = null; this._decryptedImage = null;
this._error = null; this._error = null;
this.load(); this.load();
this._lightboxUrl = this.urlCreator.urlForSegments([
// ensure the right room is active if in grid view
this.navigation.segment("room", this._room.id),
this.navigation.segment("lightbox", this._entry.id)
]);
} }
async _loadEncryptedFile(file) { async _loadEncryptedFile(file) {
const buffer = await this._mediaRepository.downloadEncryptedFile(file); const bufferHandle = await this._mediaRepository.downloadEncryptedFile(file);
if (this.isDisposed) { if (this.isDisposed) {
bufferHandle.dispose();
return; return;
} }
return this.track(this.platform.createBufferURL(buffer, file.mimetype)); return this.track(bufferHandle);
} }
async load() { async load() {
@ -54,6 +60,10 @@ export class ImageTile extends MessageTile {
} }
} }
get lightboxUrl() {
return this._lightboxUrl;
}
get thumbnailUrl() { get thumbnailUrl() {
if (this._decryptedThumbail) { if (this._decryptedThumbail) {
return this._decryptedThumbail.url; return this._decryptedThumbail.url;

View file

@ -20,12 +20,19 @@ import {getIdentifierColorNumber, avatarInitials} from "../../../../avatar.js";
export class MessageTile extends SimpleTile { export class MessageTile extends SimpleTile {
constructor(options) { constructor(options) {
super(options); super(options);
this._mediaRepository = options.mediaRepository;
this._isOwn = this._entry.sender === options.ownUserId; this._isOwn = this._entry.sender === options.ownUserId;
this._date = this._entry.timestamp ? new Date(this._entry.timestamp) : null; this._date = this._entry.timestamp ? new Date(this._entry.timestamp) : null;
this._isContinuation = false; this._isContinuation = false;
} }
get _room() {
return this.getOption("room");
}
get _mediaRepository() {
return this._room.mediaRepository;
}
get shape() { get shape() {
return "message"; return "message";
} }

View file

@ -23,12 +23,11 @@ import {RoomMemberTile} from "./tiles/RoomMemberTile.js";
import {EncryptedEventTile} from "./tiles/EncryptedEventTile.js"; import {EncryptedEventTile} from "./tiles/EncryptedEventTile.js";
import {EncryptionEnabledTile} from "./tiles/EncryptionEnabledTile.js"; import {EncryptionEnabledTile} from "./tiles/EncryptionEnabledTile.js";
export function tilesCreator({room, ownUserId, platform}) { export function tilesCreator(baseOptions) {
return function tilesCreator(entry, emitUpdate) { return function tilesCreator(entry, emitUpdate) {
const options = {entry, emitUpdate, ownUserId, platform, const options = Object.assign({entry, emitUpdate}, baseOptions);
mediaRepository: room.mediaRepository};
if (entry.isGap) { if (entry.isGap) {
return new GapTile(options, room); return new GapTile(options);
} else if (entry.eventType) { } else if (entry.eventType) {
switch (entry.eventType) { switch (entry.eventType) {
case "m.room.message": { case "m.room.message": {

View file

@ -167,8 +167,7 @@ export class SessionContainer {
this._requestScheduler.start(); this._requestScheduler.start();
const mediaRepository = new MediaRepository({ const mediaRepository = new MediaRepository({
homeServer: sessionInfo.homeServer, homeServer: sessionInfo.homeServer,
crypto: this._platform.crypto, platform: this._platform,
request: this._platform.request,
}); });
this._session = new Session({ this._session = new Session({
storage: this._storage, storage: this._storage,

View file

@ -200,7 +200,7 @@ export class Sync {
syncTxn.abort(); syncTxn.abort();
} catch (abortErr) { } catch (abortErr) {
console.error("Could not abort sync transaction, the sync response was probably only partially written and may have put storage in a inconsistent state.", abortErr); console.error("Could not abort sync transaction, the sync response was probably only partially written and may have put storage in a inconsistent state.", abortErr);
} }
throw err; throw err;
} }
try { try {

View file

@ -18,10 +18,9 @@ import {encodeQueryParams} from "./common.js";
import {decryptAttachment} from "../e2ee/attachment.js"; import {decryptAttachment} from "../e2ee/attachment.js";
export class MediaRepository { export class MediaRepository {
constructor({homeServer, crypto, request}) { constructor({homeServer, platform}) {
this._homeServer = homeServer; this._homeServer = homeServer;
this._crypto = crypto; this._platform = platform;
this._request = request;
} }
mxcUrlThumbnail(url, width, height, method) { mxcUrlThumbnail(url, width, height, method) {
@ -55,8 +54,8 @@ export class MediaRepository {
async downloadEncryptedFile(fileEntry) { async downloadEncryptedFile(fileEntry) {
const url = this.mxcUrl(fileEntry.url); const url = this.mxcUrl(fileEntry.url);
const {body: encryptedBuffer} = await this._request(url, {method: "GET", format: "buffer", cache: true}).response(); const {body: encryptedBuffer} = await this._platform.request(url, {method: "GET", format: "buffer", cache: true}).response();
const decryptedBuffer = await decryptAttachment(this._crypto, encryptedBuffer, fileEntry); const decryptedBuffer = await decryptAttachment(this._platform.crypto, encryptedBuffer, fileEntry);
return decryptedBuffer; return this._platform.createBufferHandle(decryptedBuffer, fileEntry.mimetype);
} }
} }

View file

@ -0,0 +1,90 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {BaseObservableValue} from "../../observable/ObservableValue.js";
export class ObservedEventMap {
constructor(notifyEmpty) {
this._map = new Map();
this._notifyEmpty = notifyEmpty;
}
observe(eventId, eventEntry = null) {
let observable = this._map.get(eventId);
if (!observable) {
observable = new ObservedEvent(this, eventEntry);
this._map.set(eventId, observable);
}
return observable;
}
updateEvents(eventEntries) {
for (let i = 0; i < eventEntries.length; i += 1) {
const entry = eventEntries[i];
const observable = this._map.get(entry.id);
observable?.update(entry);
}
}
_remove(observable) {
this._map.delete(observable.get().id);
if (this._map.size === 0) {
this._notifyEmpty();
}
}
}
class ObservedEvent extends BaseObservableValue {
constructor(eventMap, entry) {
super();
this._eventMap = eventMap;
this._entry = entry;
// remove subscription in microtask after creating it
// otherwise ObservedEvents would easily never get
// removed if you never subscribe
Promise.resolve().then(() => {
if (!this.hasSubscriptions) {
this._eventMap.remove(this);
this._eventMap = null;
}
});
}
subscribe(handler) {
if (!this._eventMap) {
throw new Error("ObservedEvent expired, subscribe right after calling room.observeEvent()");
}
return super.subscribe(handler);
}
onUnsubscribeLast() {
this._eventMap._remove(this);
this._eventMap = null;
super.onUnsubscribeLast();
}
update(entry) {
// entries are mostly updated in-place,
// apart from when they are created,
// but doesn't hurt to reassign
this._entry = entry;
this.emit(this._entry);
}
get() {
return this._entry;
}
}

View file

@ -29,8 +29,8 @@ import {Heroes} from "./members/Heroes.js";
import {EventEntry} from "./timeline/entries/EventEntry.js"; import {EventEntry} from "./timeline/entries/EventEntry.js";
import {EventKey} from "./timeline/EventKey.js"; import {EventKey} from "./timeline/EventKey.js";
import {Direction} from "./timeline/Direction.js"; import {Direction} from "./timeline/Direction.js";
import {ObservedEventMap} from "./ObservedEventMap.js";
import {DecryptionSource} from "../e2ee/common.js"; import {DecryptionSource} from "../e2ee/common.js";
const EVENT_ENCRYPTED_TYPE = "m.room.encrypted"; const EVENT_ENCRYPTED_TYPE = "m.room.encrypted";
export class Room extends EventEmitter { export class Room extends EventEmitter {
@ -53,6 +53,7 @@ export class Room extends EventEmitter {
this._roomEncryption = null; this._roomEncryption = null;
this._getSyncToken = getSyncToken; this._getSyncToken = getSyncToken;
this._clock = clock; this._clock = clock;
this._observedEvents = null;
} }
_readRetryDecryptCandidateEntries(sinceEventKey, txn) { _readRetryDecryptCandidateEntries(sinceEventKey, txn) {
@ -165,6 +166,9 @@ export class Room extends EventEmitter {
} }
await writeTxn.complete(); await writeTxn.complete();
decryption.applyToEntries(entries); decryption.applyToEntries(entries);
if (this._observedEvents) {
this._observedEvents.updateEvents(entries);
}
}); });
return request; return request;
} }
@ -285,6 +289,9 @@ export class Room extends EventEmitter {
if (this._timeline) { if (this._timeline) {
this._timeline.appendLiveEntries(newTimelineEntries); this._timeline.appendLiveEntries(newTimelineEntries);
} }
if (this._observedEvents) {
this._observedEvents.updateEvents(newTimelineEntries);
}
if (removedPendingEvents) { if (removedPendingEvents) {
this._sendQueue.emitRemovals(removedPendingEvents); this._sendQueue.emitRemovals(removedPendingEvents);
} }
@ -580,6 +587,45 @@ export class Room extends EventEmitter {
this._summary.applyChanges(changes); this._summary.applyChanges(changes);
} }
observeEvent(eventId) {
if (!this._observedEvents) {
this._observedEvents = new ObservedEventMap(() => {
this._observedEvents = null;
});
}
let entry = null;
if (this._timeline) {
entry = this._timeline.getByEventId(eventId);
}
const observable = this._observedEvents.observe(eventId, entry);
if (!entry) {
// update in the background
this._readEventById(eventId).then(entry => {
observable.update(entry);
}).catch(err => {
console.warn(`could not load event ${eventId} from storage`, err);
});
}
return observable;
}
async _readEventById(eventId) {
let stores = [this._storage.storeNames.timelineEvents];
if (this.isEncrypted) {
stores.push(this._storage.storeNames.inboundGroupSessions);
}
const txn = this._storage.readTxn(stores);
const storageEntry = await txn.timelineEvents.getByEventId(this._roomId, eventId);
if (storageEntry) {
const entry = new EventEntry(storageEntry, this._fragmentIdComparer);
if (entry.eventType === EVENT_ENCRYPTED_TYPE) {
const request = this._decryptEntries(DecryptionSource.Timeline, [entry], txn);
await request.complete();
}
return entry;
}
}
dispose() { dispose() {
this._roomEncryption?.dispose(); this._roomEncryption?.dispose();
this._timeline?.dispose(); this._timeline?.dispose();

View file

@ -95,6 +95,15 @@ export class Timeline {
} }
} }
getByEventId(eventId) {
for (let i = 0; i < this._remoteEntries.length; i += 1) {
const entry = this._remoteEntries.get(i);
if (entry.id === eventId) {
return entry;
}
}
}
/** @public */ /** @public */
get entries() { get entries() {
return this._allEntries; return this._allEntries;

View file

@ -48,6 +48,10 @@ export class BaseObservable {
return null; return null;
} }
get hasSubscriptions() {
return this._handlers.size !== 0;
}
// Add iterator over handlers here // Add iterator over handlers here
} }

View file

@ -27,7 +27,7 @@ import {OnlineStatus} from "./dom/OnlineStatus.js";
import {Crypto} from "./dom/Crypto.js"; import {Crypto} from "./dom/Crypto.js";
import {estimateStorageUsage} from "./dom/StorageEstimate.js"; import {estimateStorageUsage} from "./dom/StorageEstimate.js";
import {WorkerPool} from "./dom/WorkerPool.js"; import {WorkerPool} from "./dom/WorkerPool.js";
import {BufferURL} from "./dom/BufferURL.js"; import {BufferHandle} from "./dom/BufferHandle.js";
function addScript(src) { function addScript(src) {
return new Promise(function (resolve, reject) { return new Promise(function (resolve, reject) {
@ -99,6 +99,8 @@ export class Platform {
} else { } else {
this.request = xhrRequest; this.request = xhrRequest;
} }
const isIE11 = !!window.MSInputMethodContext && !!document.documentMode;
this.isIE11 = isIE11;
} }
get updateService() { get updateService() {
@ -116,8 +118,7 @@ export class Platform {
} }
createAndMountRootView(vm) { createAndMountRootView(vm) {
const isIE11 = !!window.MSInputMethodContext && !!document.documentMode; if (this.isIE11) {
if (isIE11) {
this._container.className += " legacy"; this._container.className += " legacy";
} }
window.__hydrogenViewModel = vm; window.__hydrogenViewModel = vm;
@ -129,7 +130,7 @@ export class Platform {
this._serviceWorkerHandler?.setNavigation(navigation); this._serviceWorkerHandler?.setNavigation(navigation);
} }
createBufferURL(buffer, mimetype) { createBufferHandle(buffer, mimetype) {
return new BufferURL(buffer, mimetype); return new BufferHandle(buffer, mimetype);
} }
} }

View file

@ -69,18 +69,27 @@ const ALLOWED_BLOB_MIMETYPES = {
'audio/x-flac': true, 'audio/x-flac': true,
}; };
export class BufferURL { export class BufferHandle {
constructor(buffer, mimetype) { constructor(buffer, mimetype) {
mimetype = mimetype ? mimetype.split(";")[0].trim() : ''; mimetype = mimetype ? mimetype.split(";")[0].trim() : '';
if (!ALLOWED_BLOB_MIMETYPES[mimetype]) { if (!ALLOWED_BLOB_MIMETYPES[mimetype]) {
mimetype = 'application/octet-stream'; mimetype = 'application/octet-stream';
} }
const blob = new Blob([buffer], {type: mimetype}); this.blob = new Blob([buffer], {type: mimetype});
this.url = URL.createObjectURL(blob); this._url = null;
}
get url() {
if (!this._url) {
this._url = URL.createObjectURL(this.blob);
}
return this._url;
} }
dispose() { dispose() {
URL.revokeObjectURL(this.url); if (this._url) {
this.url = null; URL.revokeObjectURL(this._url);
this._url = null;
}
} }
} }

View file

@ -19,6 +19,11 @@ html {
height: 100%; height: 100%;
} }
/* unknown element in IE11 that defaults to inline */
main {
display: block;
}
@media screen and (min-width: 600px) { @media screen and (min-width: 600px) {
.PreSessionScreen { .PreSessionScreen {
width: 600px; width: 600px;
@ -34,46 +39,60 @@ html {
} }
.SessionView { .SessionView {
display: flex;
flex-direction: column;
/* this takes into account whether or not the url bar is hidden on mobile /* this takes into account whether or not the url bar is hidden on mobile
(have tested Firefox Android and Safari on iOS), (have tested Firefox Android and Safari on iOS),
see https://developers.google.com/web/updates/2016/12/url-bar-resizing */ see https://developers.google.com/web/updates/2016/12/url-bar-resizing */
position: fixed; position: fixed;
height: 100%; height: 100%;
} width: 100%;
display: grid;
.SessionView > .main { grid-template:
flex: 1; "status status" auto
display: flex; "left middle" 1fr /
300px 1fr;
min-height: 0; min-height: 0;
min-width: 0; min-width: 0;
width: 100vw;
} }
/* hide back button in middle section by default */ /* hide back button in middle section by default */
.middle .close-middle { display: none; } .middle .close-middle { display: none; }
/* mobile layout */ /* mobile layout */
@media screen and (max-width: 800px) { @media screen and (max-width: 800px) {
.SessionView:not(.middle-shown) {
grid-template:
"status" auto
"left" 1fr /
1fr;
}
.SessionView.middle-shown {
grid-template:
"status" auto
"middle" 1fr /
1fr;
}
.SessionView:not(.middle-shown) .room-placeholder { display: none; }
.SessionView.middle-shown .LeftPanel { display: none; }
/* show back button */ /* show back button */
.middle .close-middle { display: block !important; } .middle .close-middle { display: block !important; }
/* hide grid button */ /* hide grid button */
.LeftPanel .grid { display: none !important; } .LeftPanel .grid { display: none !important; }
div.middle, div.room-placeholder { display: none; }
div.LeftPanel {flex-grow: 1;}
div.middle-shown div.middle { display: flex; }
div.middle-shown div.LeftPanel { display: none; }
div.right-shown div.TimelinePanel { display: none; }
} }
.LeftPanel { .LeftPanel {
flex: 0 0 300px; grid-area: left;
min-width: 0; min-width: 0;
} }
.room-placeholder, .middle { .room-placeholder, .middle {
flex: 1 0 0;
min-width: 0; min-width: 0;
grid-area: middle;
/* when room view is inside of a grid,
grid-area middle won't be found,
so set width manually */
width: 100%;
} }
.RoomView { .RoomView {
@ -81,6 +100,19 @@ html {
display: flex; display: flex;
} }
.SessionStatusView {
grid-area: status;
}
.lightbox {
/* cover left and middle panel, not status view
use numeric positions because named grid areas
are not present in mobile layout */
grid-area: 2 / 1 / 3 / 3;
/* this should not be necessary, but chrome seems to have a bug when there are scrollbars in other grid items,
it seems to put the scroll areas on top of the other grid items unless they have a z-index */
z-index: 1;
}
.TimelinePanel { .TimelinePanel {
flex: 3; flex: 3;

View file

@ -494,6 +494,8 @@ ul.Timeline > li.messageStatus .message-container > p {
.message-container { .message-container {
padding: 1px 10px 0px 10px; padding: 1px 10px 0px 10px;
margin: 5px 10px 0 10px; margin: 5px 10px 0 10px;
/* so the .picture can grow horizontally and its spacer can grow vertically */
width: 100%;
} }
.message-container .profile { .message-container .profile {
@ -505,9 +507,8 @@ ul.Timeline > li.messageStatus .message-container > p {
--avatar-size: 25px; --avatar-size: 25px;
} }
.message-container img.picture { .TextMessageView {
margin-top: 4px; width: 100%;
border-radius: 4px;
} }
.TextMessageView.continuation .message-container { .TextMessageView.continuation .message-container {
@ -538,6 +539,46 @@ ul.Timeline > li.messageStatus .message-container > p {
color: #aaa; color: #aaa;
} }
.message-container .picture {
display: grid;
text-decoration: none;
margin-top: 4px;
width: 100%;
}
/* .spacer grows with an inline padding-top to the size of the image,
so the timeline doesn't jump when the image loads */
.message-container .picture > * {
grid-row: 1;
grid-column: 1;
}
.message-container .picture > img {
width: 100%;
height: auto;
/* for IE11 to still scale even though the spacer is too tall */
align-self: start;
border-radius: 4px;
}
.message-container .picture > time {
align-self: end;
justify-self: end;
color: #2e2f32;
display: block;
padding: 2px;
margin: 4px;
background-color: rgba(255, 255, 255, 0.75);
border-radius: 4px;
}
.message-container .picture > .spacer {
/* TODO: can we implement this with a pseudo element? or perhaps they are not grid items? */
width: 100%;
/* don't stretch height as it is a spacer, just in case it doesn't match with image height */
align-self: start;
}
.TextMessageView.pending .message-container { .TextMessageView.pending .message-container {
color: #ccc; color: #ccc;
} }
@ -632,3 +673,72 @@ button.link {
color: #03B381; color: #03B381;
font-weight: 600; font-weight: 600;
} }
.lightbox {
background-color: rgba(0,0,0,0.75);
display: grid;
grid-template:
"content close" auto
"content details" 1fr /
1fr auto;
color: white;
padding: 4px;
}
@media (max-aspect-ratio: 1/1) {
.lightbox {
grid-template:
"close" auto
"content" 1fr
"details" auto /
1fr;
}
.lightbox .details {
width: 100% !important;
}
}
.lightbox .picture {
grid-area: content;
background-size: contain;
background-position: center;
background-repeat: no-repeat;
width: 100%;
height: 100%;
align-self: center;
justify-self: center;
}
.lightbox .loading {
grid-area: content;
align-self: center;
justify-self: center;
display: flex;
}
.lightbox .loading > :not(:first-child) {
margin-left: 8px;
}
.lightbox .close {
display: block;
grid-area: close;
justify-self: end;
background-image: url('icons/dismiss.svg');
background-position: center;
background-size: 16px;
background-repeat: no-repeat;
width: 16px;
height: 16px;
padding: 12px;
}
.lightbox .details {
grid-area: details;
padding: 12px;
font-size: 1.5rem;
width: 200px;
}

View file

@ -37,23 +37,12 @@ limitations under the License.
margin: 5px 0; margin: 5px 0;
} }
.message-container a { .message-container .picture {
display: block; display: block;
position: relative;
max-width: 100%;
/* width and padding-top set inline to maintain aspect ratio,
replace with css aspect-ratio once supported */
} }
.message-container img.picture { .message-container .picture > img {
display: block; display: block;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
width: 100%;
height: 100%;
} }
.TextMessageView { .TextMessageView {

View file

@ -74,16 +74,16 @@ export class TemplateView {
_attach() { _attach() {
if (this._eventListeners) { if (this._eventListeners) {
for (let {node, name, fn} of this._eventListeners) { for (let {node, name, fn, useCapture} of this._eventListeners) {
node.addEventListener(name, fn); node.addEventListener(name, fn, useCapture);
} }
} }
} }
_detach() { _detach() {
if (this._eventListeners) { if (this._eventListeners) {
for (let {node, name, fn} of this._eventListeners) { for (let {node, name, fn, useCapture} of this._eventListeners) {
node.removeEventListener(name, fn); node.removeEventListener(name, fn, useCapture);
} }
} }
} }
@ -132,11 +132,11 @@ export class TemplateView {
} }
} }
_addEventListener(node, name, fn) { _addEventListener(node, name, fn, useCapture = false) {
if (!this._eventListeners) { if (!this._eventListeners) {
this._eventListeners = []; this._eventListeners = [];
} }
this._eventListeners.push({node, name, fn}); this._eventListeners.push({node, name, fn, useCapture});
} }
_addBinding(bindingFn) { _addBinding(bindingFn) {
@ -164,6 +164,10 @@ class TemplateBuilder {
return this._templateView._value; return this._templateView._value;
} }
addEventListener(node, name, fn, useCapture = false) {
this._templateView._addEventListener(node, name, fn, useCapture);
}
_addAttributeBinding(node, name, fn) { _addAttributeBinding(node, name, fn) {
let prevValue = undefined; let prevValue = undefined;
const binding = () => { const binding = () => {

View file

@ -35,7 +35,7 @@ export class LoginView extends TemplateView {
}); });
const homeserver = t.input({ const homeserver = t.input({
id: "homeserver", id: "homeserver",
type: "text", type: "url",
placeholder: vm.i18n`Your matrix homeserver`, placeholder: vm.i18n`Your matrix homeserver`,
value: vm.defaultHomeServer, value: vm.defaultHomeServer,
disabled disabled

View file

@ -17,6 +17,7 @@ limitations under the License.
import {LeftPanelView} from "./leftpanel/LeftPanelView.js"; import {LeftPanelView} from "./leftpanel/LeftPanelView.js";
import {RoomView} from "./room/RoomView.js"; import {RoomView} from "./room/RoomView.js";
import {LightboxView} from "./room/LightboxView.js";
import {TemplateView} from "../general/TemplateView.js"; import {TemplateView} from "../general/TemplateView.js";
import {StaticView} from "../general/StaticView.js"; import {StaticView} from "../general/StaticView.js";
import {SessionStatusView} from "./SessionStatusView.js"; import {SessionStatusView} from "./SessionStatusView.js";
@ -32,21 +33,20 @@ export class SessionView extends TemplateView {
}, },
}, [ }, [
t.view(new SessionStatusView(vm.sessionStatusViewModel)), t.view(new SessionStatusView(vm.sessionStatusViewModel)),
t.div({className: "main"}, [ t.view(new LeftPanelView(vm.leftPanelViewModel)),
t.view(new LeftPanelView(vm.leftPanelViewModel)), t.mapView(vm => vm.activeSection, activeSection => {
t.mapView(vm => vm.activeSection, activeSection => { switch (activeSection) {
switch (activeSection) { case "roomgrid":
case "roomgrid": return new RoomGridView(vm.roomGridViewModel);
return new RoomGridView(vm.roomGridViewModel); case "placeholder":
case "placeholder": return new StaticView(t => t.div({className: "room-placeholder"}, t.h2(vm.i18n`Choose a room on the left side.`)));
return new StaticView(t => t.div({className: "room-placeholder"}, t.h2(vm.i18n`Choose a room on the left side.`))); case "settings":
case "settings": return new SettingsView(vm.settingsViewModel);
return new SettingsView(vm.settingsViewModel); default: //room id
default: //room id return new RoomView(vm.currentRoomViewModel);
return new RoomView(vm.currentRoomViewModel); }
} }),
}) t.mapView(vm => vm.lightboxViewModel, lightboxViewModel => lightboxViewModel ? new LightboxView(lightboxViewModel) : null)
])
]); ]);
} }
} }

View file

@ -0,0 +1,96 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {TemplateView} from "../../general/TemplateView.js";
import {spinner} from "../../common.js";
export class LightboxView extends TemplateView {
render(t, vm) {
const close = t.a({href: vm.closeUrl, title: vm.i18n`Close`, className: "close"});
const image = t.div({
role: "img",
"aria-label": vm => vm.name,
title: vm => vm.name,
className: {
picture: true,
hidden: vm => !vm.imageUrl,
},
style: vm => `background-image: url('${vm.imageUrl}'); max-width: ${vm.imageWidth}px; max-height: ${vm.imageHeight}px;`
});
const loading = t.div({
className: {
loading: true,
hidden: vm => !!vm.imageUrl
}
}, [
spinner(t),
t.div(vm.i18n`Loading image…`)
]);
const details = t.div({
className: "details"
}, [t.strong(vm => vm.name), t.br(), "uploaded by ", t.strong(vm => vm.sender), vm => ` at ${vm.time} on ${vm.date}.`]);
const dialog = t.div({
role: "dialog",
className: "lightbox",
onClick: evt => this.clickToClose(evt),
onKeydown: evt => this.closeOnEscKey(evt)
}, [image, loading, details, close]);
trapFocus(t, dialog);
return dialog;
}
clickToClose(evt) {
if (evt.target === this.root()) {
this.value.close();
}
}
closeOnEscKey(evt) {
if (evt.key === "Escape" || evt.key === "Esc") {
this.value.close();
}
}
}
function trapFocus(t, element) {
const elements = focusables(element);
const first = elements[0];
const last = elements[elements.length - 1];
t.addEventListener(element, "keydown", evt => {
if (evt.key === "Tab") {
if (evt.shiftKey) {
if (document.activeElement === first) {
last.focus();
evt.preventDefault();
}
} else {
if (document.activeElement === last) {
first.focus();
evt.preventDefault();
}
}
}
}, true);
Promise.resolve().then(() => {
first.focus();
});
}
function focusables(element) {
return element.querySelectorAll('a[href], button, textarea, input, select');
}

View file

@ -19,26 +19,31 @@ import {renderMessage} from "./common.js";
export class ImageView extends TemplateView { export class ImageView extends TemplateView {
render(t, vm) { render(t, vm) {
// replace with css aspect-ratio once supported const heightRatioPercent = (vm.thumbnailHeight / vm.thumbnailWidth) * 100;
const heightRatioPercent = (vm.thumbnailHeight / vm.thumbnailWidth) * 100; let spacerStyle = `padding-top: ${heightRatioPercent}%;`;
const image = t.img({ if (vm.platform.isIE11) {
className: "picture", // preserving aspect-ratio in a grid with padding percentages
src: vm => vm.thumbnailUrl, // does not work in IE11, so we assume people won't use it
width: vm.thumbnailWidth, // with viewports narrower than 400px where thumbnails will get
height: vm.thumbnailHeight, // scaled. If they do, the thumbnail will still scale, but
loading: "lazy", // there will be whitespace underneath the picture
alt: vm => vm.label, // An alternative would be to use position: absolute but that
title: vm => vm.label, // can slow down rendering, and was bleeding through the lightbox.
}); spacerStyle = `height: ${vm.thumbnailHeight}px`;
const linkContainer = t.a({ }
style: `padding-top: ${heightRatioPercent}%; width: ${vm.thumbnailWidth}px;` return renderMessage(t, vm, [
}, [ t.a({href: vm.lightboxUrl, className: "picture", style: `max-width: ${vm.thumbnailWidth}px`}, [
image, t.div({className: "spacer", style: spacerStyle}),
t.img({
loading: "lazy",
src: vm => vm.thumbnailUrl,
alt: vm => vm.label,
title: vm => vm.label,
style: `max-width: ${vm.thumbnailWidth}px; max-height: ${vm.thumbnailHeight}px;`
}),
t.time(vm.date + " " + vm.time),
]),
t.if(vm => vm.error, t.createTemplate((t, vm) => t.p({className: "error"}, vm.error))) t.if(vm => vm.error, t.createTemplate((t, vm) => t.p({className: "error"}, vm.error)))
]); ]);
return renderMessage(t, vm,
[t.div(linkContainer), t.p(t.time(vm.date + " " + vm.time))]
);
} }
} }