diff --git a/src/domain/navigation/index.js b/src/domain/navigation/index.js index ec593122..44f81026 100644 --- a/src/domain/navigation/index.js +++ b/src/domain/navigation/index.js @@ -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; } diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 79d8d87c..2f7e341e 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -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; + } } diff --git a/src/domain/session/room/LightboxViewModel.js b/src/domain/session/room/LightboxViewModel.js new file mode 100644 index 00000000..f6da39b0 --- /dev/null +++ b/src/domain/session/room/LightboxViewModel.js @@ -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); + } +} diff --git a/src/domain/session/room/timeline/TimelineViewModel.js b/src/domain/session/room/timeline/TimelineViewModel.js index 7366641b..e3d92171 100644 --- a/src/domain/session/room/timeline/TimelineViewModel.js +++ b/src/domain/session/room/timeline/TimelineViewModel.js @@ -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() { diff --git a/src/domain/session/room/timeline/tiles/GapTile.js b/src/domain/session/room/timeline/tiles/GapTile.js index 98d197b9..c2cf2f56 100644 --- a/src/domain/session/room/timeline/tiles/GapTile.js +++ b/src/domain/session/room/timeline/tiles/GapTile.js @@ -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 diff --git a/src/domain/session/room/timeline/tiles/ImageTile.js b/src/domain/session/room/timeline/tiles/ImageTile.js index 04cff3c9..1e31e414 100644 --- a/src/domain/session/room/timeline/tiles/ImageTile.js +++ b/src/domain/session/room/timeline/tiles/ImageTile.js @@ -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; diff --git a/src/domain/session/room/timeline/tiles/MessageTile.js b/src/domain/session/room/timeline/tiles/MessageTile.js index 36d08ca7..fe566814 100644 --- a/src/domain/session/room/timeline/tiles/MessageTile.js +++ b/src/domain/session/room/timeline/tiles/MessageTile.js @@ -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"; } diff --git a/src/domain/session/room/timeline/tilesCreator.js b/src/domain/session/room/timeline/tilesCreator.js index d682d22e..549ad65d 100644 --- a/src/domain/session/room/timeline/tilesCreator.js +++ b/src/domain/session/room/timeline/tilesCreator.js @@ -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": { diff --git a/src/matrix/SessionContainer.js b/src/matrix/SessionContainer.js index e0fcf951..4f28c946 100644 --- a/src/matrix/SessionContainer.js +++ b/src/matrix/SessionContainer.js @@ -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, diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index 51f66993..4e400b4e 100644 --- a/src/matrix/Sync.js +++ b/src/matrix/Sync.js @@ -200,7 +200,7 @@ export class Sync { syncTxn.abort(); } 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); - } + } throw err; } try { diff --git a/src/matrix/net/MediaRepository.js b/src/matrix/net/MediaRepository.js index 856c6657..a20b6d1c 100644 --- a/src/matrix/net/MediaRepository.js +++ b/src/matrix/net/MediaRepository.js @@ -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); } } diff --git a/src/matrix/room/ObservedEventMap.js b/src/matrix/room/ObservedEventMap.js new file mode 100644 index 00000000..1e21df63 --- /dev/null +++ b/src/matrix/room/ObservedEventMap.js @@ -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; + } +} diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index f12da45b..b2d2b635 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -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(); diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index 8cad17a1..1b7c8a18 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -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; diff --git a/src/observable/BaseObservable.js b/src/observable/BaseObservable.js index 660f3200..29387020 100644 --- a/src/observable/BaseObservable.js +++ b/src/observable/BaseObservable.js @@ -48,6 +48,10 @@ export class BaseObservable { return null; } + get hasSubscriptions() { + return this._handlers.size !== 0; + } + // Add iterator over handlers here } diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js index dc299ef8..87b055cd 100644 --- a/src/platform/web/Platform.js +++ b/src/platform/web/Platform.js @@ -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); } } diff --git a/src/platform/web/dom/BufferURL.js b/src/platform/web/dom/BufferHandle.js similarity index 89% rename from src/platform/web/dom/BufferURL.js rename to src/platform/web/dom/BufferHandle.js index 28730022..80bb40bb 100644 --- a/src/platform/web/dom/BufferURL.js +++ b/src/platform/web/dom/BufferHandle.js @@ -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; + } } } diff --git a/src/platform/web/ui/css/layout.css b/src/platform/web/ui/css/layout.css index 2ee584b0..eb3f8355 100644 --- a/src/platform/web/ui/css/layout.css +++ b/src/platform/web/ui/css/layout.css @@ -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; diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 9473b307..a87b3715 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -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; +} + + diff --git a/src/platform/web/ui/css/timeline.css b/src/platform/web/ui/css/timeline.css index 44ff29d6..ee9ffdbb 100644 --- a/src/platform/web/ui/css/timeline.css +++ b/src/platform/web/ui/css/timeline.css @@ -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 { diff --git a/src/platform/web/ui/general/TemplateView.js b/src/platform/web/ui/general/TemplateView.js index 80c2cf2e..8158fcb3 100644 --- a/src/platform/web/ui/general/TemplateView.js +++ b/src/platform/web/ui/general/TemplateView.js @@ -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 = () => { diff --git a/src/platform/web/ui/login/LoginView.js b/src/platform/web/ui/login/LoginView.js index e03eab6b..1196295e 100644 --- a/src/platform/web/ui/login/LoginView.js +++ b/src/platform/web/ui/login/LoginView.js @@ -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 diff --git a/src/platform/web/ui/session/SessionView.js b/src/platform/web/ui/session/SessionView.js index fbd3eb23..fa7a492a 100644 --- a/src/platform/web/ui/session/SessionView.js +++ b/src/platform/web/ui/session/SessionView.js @@ -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,21 +33,20 @@ 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) { - case "roomgrid": - return new RoomGridView(vm.roomGridViewModel); - case "placeholder": - return new StaticView(t => t.div({className: "room-placeholder"}, t.h2(vm.i18n`Choose a room on the left side.`))); - case "settings": - return new SettingsView(vm.settingsViewModel); - default: //room id - return new RoomView(vm.currentRoomViewModel); - } - }) - ]) + t.view(new LeftPanelView(vm.leftPanelViewModel)), + t.mapView(vm => vm.activeSection, activeSection => { + switch (activeSection) { + case "roomgrid": + return new RoomGridView(vm.roomGridViewModel); + case "placeholder": + return new StaticView(t => t.div({className: "room-placeholder"}, t.h2(vm.i18n`Choose a room on the left side.`))); + case "settings": + return new SettingsView(vm.settingsViewModel); + default: //room id + return new RoomView(vm.currentRoomViewModel); + } + }), + t.mapView(vm => vm.lightboxViewModel, lightboxViewModel => lightboxViewModel ? new LightboxView(lightboxViewModel) : null) ]); } } diff --git a/src/platform/web/ui/session/room/LightboxView.js b/src/platform/web/ui/session/room/LightboxView.js new file mode 100644 index 00000000..16d5666f --- /dev/null +++ b/src/platform/web/ui/session/room/LightboxView.js @@ -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'); +} + diff --git a/src/platform/web/ui/session/room/timeline/ImageView.js b/src/platform/web/ui/session/room/timeline/ImageView.js index 113fb1e4..eb060e34 100644 --- a/src/platform/web/ui/session/room/timeline/ImageView.js +++ b/src/platform/web/ui/session/room/timeline/ImageView.js @@ -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, - loading: "lazy", - alt: vm => vm.label, - title: vm => vm.label, - }); - const linkContainer = t.a({ - style: `padding-top: ${heightRatioPercent}%; width: ${vm.thumbnailWidth}px;` - }, [ - image, + const heightRatioPercent = (vm.thumbnailHeight / vm.thumbnailWidth) * 100; + 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, + 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))] - ); } }