Merge pull request #178 from vector-im/bwindels/lightbox
Lightbox for picture messages
This commit is contained in:
commit
a3ec01385b
25 changed files with 638 additions and 104 deletions
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
97
src/domain/session/room/LightboxViewModel.js
Normal file
97
src/domain/session/room/LightboxViewModel.js
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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() {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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";
|
||||
}
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
90
src/matrix/room/ObservedEventMap.js
Normal file
90
src/matrix/room/ObservedEventMap.js
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -48,6 +48,10 @@ export class BaseObservable {
|
|||
return null;
|
||||
}
|
||||
|
||||
get hasSubscriptions() {
|
||||
return this._handlers.size !== 0;
|
||||
}
|
||||
|
||||
// Add iterator over handlers here
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 = () => {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
96
src/platform/web/ui/session/room/LightboxView.js
Normal file
96
src/platform/web/ui/session/room/LightboxView.js
Normal 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');
|
||||
}
|
||||
|
|
@ -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))]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
Reference in a new issue