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":
// downside of the approach: both of these will control which tile is selected
return type === "room" || type === "empty-grid-tile";
case "room":
return type === "lightbox";
default:
return false;
}

View file

@ -17,6 +17,7 @@ limitations under the License.
import {LeftPanelViewModel} from "./leftpanel/LeftPanelViewModel.js";
import {RoomViewModel} from "./room/RoomViewModel.js";
import {LightboxViewModel} from "./room/LightboxViewModel.js";
import {SessionStatusViewModel} from "./SessionStatusViewModel.js";
import {RoomGridViewModel} from "./RoomGridViewModel.js";
import {SettingsViewModel} from "./settings/SettingsViewModel.js";
@ -67,6 +68,12 @@ export class SessionViewModel extends ViewModel {
this._updateSettings(settingsOpen);
}));
this._updateSettings(settings.get());
const lightbox = this.navigation.observe("lightbox");
this.track(lightbox.subscribe(eventId => {
this._updateLightbox(eventId);
}));
this._updateLightbox(lightbox.get());
}
get id() {
@ -194,4 +201,20 @@ export class SessionViewModel extends ViewModel {
}
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
// timeline.entries.concat(timeline.pendingEvents)
// 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() {

View file

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

View file

@ -27,14 +27,20 @@ export class ImageTile extends MessageTile {
this._decryptedImage = null;
this._error = null;
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) {
const buffer = await this._mediaRepository.downloadEncryptedFile(file);
const bufferHandle = await this._mediaRepository.downloadEncryptedFile(file);
if (this.isDisposed) {
bufferHandle.dispose();
return;
}
return this.track(this.platform.createBufferURL(buffer, file.mimetype));
return this.track(bufferHandle);
}
async load() {
@ -54,6 +60,10 @@ export class ImageTile extends MessageTile {
}
}
get lightboxUrl() {
return this._lightboxUrl;
}
get thumbnailUrl() {
if (this._decryptedThumbail) {
return this._decryptedThumbail.url;

View file

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

View file

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

View file

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

View file

@ -18,10 +18,9 @@ import {encodeQueryParams} from "./common.js";
import {decryptAttachment} from "../e2ee/attachment.js";
export class MediaRepository {
constructor({homeServer, crypto, request}) {
constructor({homeServer, platform}) {
this._homeServer = homeServer;
this._crypto = crypto;
this._request = request;
this._platform = platform;
}
mxcUrlThumbnail(url, width, height, method) {
@ -55,8 +54,8 @@ export class MediaRepository {
async downloadEncryptedFile(fileEntry) {
const url = this.mxcUrl(fileEntry.url);
const {body: encryptedBuffer} = await this._request(url, {method: "GET", format: "buffer", cache: true}).response();
const decryptedBuffer = await decryptAttachment(this._crypto, encryptedBuffer, fileEntry);
return decryptedBuffer;
const {body: encryptedBuffer} = await this._platform.request(url, {method: "GET", format: "buffer", cache: true}).response();
const decryptedBuffer = await decryptAttachment(this._platform.crypto, encryptedBuffer, fileEntry);
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 {EventKey} from "./timeline/EventKey.js";
import {Direction} from "./timeline/Direction.js";
import {ObservedEventMap} from "./ObservedEventMap.js";
import {DecryptionSource} from "../e2ee/common.js";
const EVENT_ENCRYPTED_TYPE = "m.room.encrypted";
export class Room extends EventEmitter {
@ -53,6 +53,7 @@ export class Room extends EventEmitter {
this._roomEncryption = null;
this._getSyncToken = getSyncToken;
this._clock = clock;
this._observedEvents = null;
}
_readRetryDecryptCandidateEntries(sinceEventKey, txn) {
@ -165,6 +166,9 @@ export class Room extends EventEmitter {
}
await writeTxn.complete();
decryption.applyToEntries(entries);
if (this._observedEvents) {
this._observedEvents.updateEvents(entries);
}
});
return request;
}
@ -285,6 +289,9 @@ export class Room extends EventEmitter {
if (this._timeline) {
this._timeline.appendLiveEntries(newTimelineEntries);
}
if (this._observedEvents) {
this._observedEvents.updateEvents(newTimelineEntries);
}
if (removedPendingEvents) {
this._sendQueue.emitRemovals(removedPendingEvents);
}
@ -580,6 +587,45 @@ export class Room extends EventEmitter {
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() {
this._roomEncryption?.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 */
get entries() {
return this._allEntries;

View file

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

View file

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

View file

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

View file

@ -19,6 +19,11 @@ html {
height: 100%;
}
/* unknown element in IE11 that defaults to inline */
main {
display: block;
}
@media screen and (min-width: 600px) {
.PreSessionScreen {
width: 600px;
@ -34,46 +39,60 @@ html {
}
.SessionView {
display: flex;
flex-direction: column;
/* this takes into account whether or not the url bar is hidden on mobile
(have tested Firefox Android and Safari on iOS),
see https://developers.google.com/web/updates/2016/12/url-bar-resizing */
position: fixed;
height: 100%;
}
.SessionView > .main {
flex: 1;
display: flex;
width: 100%;
display: grid;
grid-template:
"status status" auto
"left middle" 1fr /
300px 1fr;
min-height: 0;
min-width: 0;
width: 100vw;
}
/* hide back button in middle section by default */
.middle .close-middle { display: none; }
/* mobile layout */
@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 */
.middle .close-middle { display: block !important; }
/* hide grid button */
.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 {
flex: 0 0 300px;
grid-area: left;
min-width: 0;
}
.room-placeholder, .middle {
flex: 1 0 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 {
@ -81,6 +100,19 @@ html {
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 {
flex: 3;

View file

@ -494,6 +494,8 @@ ul.Timeline > li.messageStatus .message-container > p {
.message-container {
padding: 1px 10px 0px 10px;
margin: 5px 10px 0 10px;
/* so the .picture can grow horizontally and its spacer can grow vertically */
width: 100%;
}
.message-container .profile {
@ -505,9 +507,8 @@ ul.Timeline > li.messageStatus .message-container > p {
--avatar-size: 25px;
}
.message-container img.picture {
margin-top: 4px;
border-radius: 4px;
.TextMessageView {
width: 100%;
}
.TextMessageView.continuation .message-container {
@ -538,6 +539,46 @@ ul.Timeline > li.messageStatus .message-container > p {
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 {
color: #ccc;
}
@ -632,3 +673,72 @@ button.link {
color: #03B381;
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;
}
.message-container a {
.message-container .picture {
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;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
width: 100%;
height: 100%;
}
.TextMessageView {

View file

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

View file

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

View file

@ -17,6 +17,7 @@ limitations under the License.
import {LeftPanelView} from "./leftpanel/LeftPanelView.js";
import {RoomView} from "./room/RoomView.js";
import {LightboxView} from "./room/LightboxView.js";
import {TemplateView} from "../general/TemplateView.js";
import {StaticView} from "../general/StaticView.js";
import {SessionStatusView} from "./SessionStatusView.js";
@ -32,7 +33,6 @@ export class SessionView extends TemplateView {
},
}, [
t.view(new SessionStatusView(vm.sessionStatusViewModel)),
t.div({className: "main"}, [
t.view(new LeftPanelView(vm.leftPanelViewModel)),
t.mapView(vm => vm.activeSection, activeSection => {
switch (activeSection) {
@ -45,8 +45,8 @@ export class SessionView extends TemplateView {
default: //room id
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 {
render(t, vm) {
// replace with css aspect-ratio once supported
const heightRatioPercent = (vm.thumbnailHeight / vm.thumbnailWidth) * 100;
const image = t.img({
className: "picture",
src: vm => vm.thumbnailUrl,
width: vm.thumbnailWidth,
height: vm.thumbnailHeight,
let spacerStyle = `padding-top: ${heightRatioPercent}%;`;
if (vm.platform.isIE11) {
// preserving aspect-ratio in a grid with padding percentages
// does not work in IE11, so we assume people won't use it
// with viewports narrower than 400px where thumbnails will get
// scaled. If they do, the thumbnail will still scale, but
// there will be whitespace underneath the picture
// An alternative would be to use position: absolute but that
// can slow down rendering, and was bleeding through the lightbox.
spacerStyle = `height: ${vm.thumbnailHeight}px`;
}
return renderMessage(t, vm, [
t.a({href: vm.lightboxUrl, className: "picture", style: `max-width: ${vm.thumbnailWidth}px`}, [
t.div({className: "spacer", style: spacerStyle}),
t.img({
loading: "lazy",
src: vm => vm.thumbnailUrl,
alt: vm => vm.label,
title: vm => vm.label,
});
const linkContainer = t.a({
style: `padding-top: ${heightRatioPercent}%; width: ${vm.thumbnailWidth}px;`
}, [
image,
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)))
]);
return renderMessage(t, vm,
[t.div(linkContainer), t.p(t.time(vm.date + " " + vm.time))]
);
}
}