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":
|
case "rooms":
|
||||||
// downside of the approach: both of these will control which tile is selected
|
// downside of the approach: both of these will control which tile is selected
|
||||||
return type === "room" || type === "empty-grid-tile";
|
return type === "room" || type === "empty-grid-tile";
|
||||||
|
case "room":
|
||||||
|
return type === "lightbox";
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,6 +17,7 @@ limitations under the License.
|
||||||
|
|
||||||
import {LeftPanelViewModel} from "./leftpanel/LeftPanelViewModel.js";
|
import {LeftPanelViewModel} from "./leftpanel/LeftPanelViewModel.js";
|
||||||
import {RoomViewModel} from "./room/RoomViewModel.js";
|
import {RoomViewModel} from "./room/RoomViewModel.js";
|
||||||
|
import {LightboxViewModel} from "./room/LightboxViewModel.js";
|
||||||
import {SessionStatusViewModel} from "./SessionStatusViewModel.js";
|
import {SessionStatusViewModel} from "./SessionStatusViewModel.js";
|
||||||
import {RoomGridViewModel} from "./RoomGridViewModel.js";
|
import {RoomGridViewModel} from "./RoomGridViewModel.js";
|
||||||
import {SettingsViewModel} from "./settings/SettingsViewModel.js";
|
import {SettingsViewModel} from "./settings/SettingsViewModel.js";
|
||||||
|
@ -67,6 +68,12 @@ export class SessionViewModel extends ViewModel {
|
||||||
this._updateSettings(settingsOpen);
|
this._updateSettings(settingsOpen);
|
||||||
}));
|
}));
|
||||||
this._updateSettings(settings.get());
|
this._updateSettings(settings.get());
|
||||||
|
|
||||||
|
const lightbox = this.navigation.observe("lightbox");
|
||||||
|
this.track(lightbox.subscribe(eventId => {
|
||||||
|
this._updateLightbox(eventId);
|
||||||
|
}));
|
||||||
|
this._updateLightbox(lightbox.get());
|
||||||
}
|
}
|
||||||
|
|
||||||
get id() {
|
get id() {
|
||||||
|
@ -194,4 +201,20 @@ export class SessionViewModel extends ViewModel {
|
||||||
}
|
}
|
||||||
this.emitChange("activeSection");
|
this.emitChange("activeSection");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_updateLightbox(eventId) {
|
||||||
|
if (this._lightboxViewModel) {
|
||||||
|
this._lightboxViewModel = this.disposeTracked(this._lightboxViewModel);
|
||||||
|
}
|
||||||
|
if (eventId) {
|
||||||
|
const roomId = this.navigation.path.get("room").value;
|
||||||
|
const room = this._sessionContainer.session.rooms.get(roomId);
|
||||||
|
this._lightboxViewModel = this.track(new LightboxViewModel(this.childOptions({eventId, room})));
|
||||||
|
}
|
||||||
|
this.emitChange("lightboxViewModel");
|
||||||
|
}
|
||||||
|
|
||||||
|
get lightboxViewModel() {
|
||||||
|
return this._lightboxViewModel;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
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
|
// once we support sending messages we could do
|
||||||
// timeline.entries.concat(timeline.pendingEvents)
|
// timeline.entries.concat(timeline.pendingEvents)
|
||||||
// for an ObservableList that also contains local echos
|
// for an ObservableList that also contains local echos
|
||||||
this._tiles = new TilesCollection(timeline.entries, tilesCreator({room, ownUserId, platform: this.platform}));
|
this._tiles = new TilesCollection(timeline.entries, tilesCreator(this.childOptions({room, ownUserId})));
|
||||||
}
|
}
|
||||||
|
|
||||||
async load() {
|
async load() {
|
||||||
|
|
|
@ -18,22 +18,25 @@ import {SimpleTile} from "./SimpleTile.js";
|
||||||
import {UpdateAction} from "../UpdateAction.js";
|
import {UpdateAction} from "../UpdateAction.js";
|
||||||
|
|
||||||
export class GapTile extends SimpleTile {
|
export class GapTile extends SimpleTile {
|
||||||
constructor(options, timeline) {
|
constructor(options) {
|
||||||
super(options);
|
super(options);
|
||||||
this._timeline = timeline;
|
|
||||||
this._loading = false;
|
this._loading = false;
|
||||||
this._error = null;
|
this._error = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get _room() {
|
||||||
|
return this.getOption("room");
|
||||||
|
}
|
||||||
|
|
||||||
async fill() {
|
async fill() {
|
||||||
// prevent doing this twice
|
// prevent doing this twice
|
||||||
if (!this._loading) {
|
if (!this._loading) {
|
||||||
this._loading = true;
|
this._loading = true;
|
||||||
this.emitChange("isLoading");
|
this.emitChange("isLoading");
|
||||||
try {
|
try {
|
||||||
await this._timeline.fillGap(this._entry, 10);
|
await this._room.fillGap(this._entry, 10);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`timeline.fillGap(): ${err.message}:\n${err.stack}`);
|
console.error(`room.fillGap(): ${err.message}:\n${err.stack}`);
|
||||||
this._error = err;
|
this._error = err;
|
||||||
this.emitChange("error");
|
this.emitChange("error");
|
||||||
// rethrow so caller of this method
|
// rethrow so caller of this method
|
||||||
|
|
|
@ -27,14 +27,20 @@ export class ImageTile extends MessageTile {
|
||||||
this._decryptedImage = null;
|
this._decryptedImage = null;
|
||||||
this._error = null;
|
this._error = null;
|
||||||
this.load();
|
this.load();
|
||||||
|
this._lightboxUrl = this.urlCreator.urlForSegments([
|
||||||
|
// ensure the right room is active if in grid view
|
||||||
|
this.navigation.segment("room", this._room.id),
|
||||||
|
this.navigation.segment("lightbox", this._entry.id)
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async _loadEncryptedFile(file) {
|
async _loadEncryptedFile(file) {
|
||||||
const buffer = await this._mediaRepository.downloadEncryptedFile(file);
|
const bufferHandle = await this._mediaRepository.downloadEncryptedFile(file);
|
||||||
if (this.isDisposed) {
|
if (this.isDisposed) {
|
||||||
|
bufferHandle.dispose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
return this.track(this.platform.createBufferURL(buffer, file.mimetype));
|
return this.track(bufferHandle);
|
||||||
}
|
}
|
||||||
|
|
||||||
async load() {
|
async load() {
|
||||||
|
@ -54,6 +60,10 @@ export class ImageTile extends MessageTile {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get lightboxUrl() {
|
||||||
|
return this._lightboxUrl;
|
||||||
|
}
|
||||||
|
|
||||||
get thumbnailUrl() {
|
get thumbnailUrl() {
|
||||||
if (this._decryptedThumbail) {
|
if (this._decryptedThumbail) {
|
||||||
return this._decryptedThumbail.url;
|
return this._decryptedThumbail.url;
|
||||||
|
|
|
@ -20,12 +20,19 @@ import {getIdentifierColorNumber, avatarInitials} from "../../../../avatar.js";
|
||||||
export class MessageTile extends SimpleTile {
|
export class MessageTile extends SimpleTile {
|
||||||
constructor(options) {
|
constructor(options) {
|
||||||
super(options);
|
super(options);
|
||||||
this._mediaRepository = options.mediaRepository;
|
|
||||||
this._isOwn = this._entry.sender === options.ownUserId;
|
this._isOwn = this._entry.sender === options.ownUserId;
|
||||||
this._date = this._entry.timestamp ? new Date(this._entry.timestamp) : null;
|
this._date = this._entry.timestamp ? new Date(this._entry.timestamp) : null;
|
||||||
this._isContinuation = false;
|
this._isContinuation = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get _room() {
|
||||||
|
return this.getOption("room");
|
||||||
|
}
|
||||||
|
|
||||||
|
get _mediaRepository() {
|
||||||
|
return this._room.mediaRepository;
|
||||||
|
}
|
||||||
|
|
||||||
get shape() {
|
get shape() {
|
||||||
return "message";
|
return "message";
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,12 +23,11 @@ import {RoomMemberTile} from "./tiles/RoomMemberTile.js";
|
||||||
import {EncryptedEventTile} from "./tiles/EncryptedEventTile.js";
|
import {EncryptedEventTile} from "./tiles/EncryptedEventTile.js";
|
||||||
import {EncryptionEnabledTile} from "./tiles/EncryptionEnabledTile.js";
|
import {EncryptionEnabledTile} from "./tiles/EncryptionEnabledTile.js";
|
||||||
|
|
||||||
export function tilesCreator({room, ownUserId, platform}) {
|
export function tilesCreator(baseOptions) {
|
||||||
return function tilesCreator(entry, emitUpdate) {
|
return function tilesCreator(entry, emitUpdate) {
|
||||||
const options = {entry, emitUpdate, ownUserId, platform,
|
const options = Object.assign({entry, emitUpdate}, baseOptions);
|
||||||
mediaRepository: room.mediaRepository};
|
|
||||||
if (entry.isGap) {
|
if (entry.isGap) {
|
||||||
return new GapTile(options, room);
|
return new GapTile(options);
|
||||||
} else if (entry.eventType) {
|
} else if (entry.eventType) {
|
||||||
switch (entry.eventType) {
|
switch (entry.eventType) {
|
||||||
case "m.room.message": {
|
case "m.room.message": {
|
||||||
|
|
|
@ -167,8 +167,7 @@ export class SessionContainer {
|
||||||
this._requestScheduler.start();
|
this._requestScheduler.start();
|
||||||
const mediaRepository = new MediaRepository({
|
const mediaRepository = new MediaRepository({
|
||||||
homeServer: sessionInfo.homeServer,
|
homeServer: sessionInfo.homeServer,
|
||||||
crypto: this._platform.crypto,
|
platform: this._platform,
|
||||||
request: this._platform.request,
|
|
||||||
});
|
});
|
||||||
this._session = new Session({
|
this._session = new Session({
|
||||||
storage: this._storage,
|
storage: this._storage,
|
||||||
|
|
|
@ -200,7 +200,7 @@ export class Sync {
|
||||||
syncTxn.abort();
|
syncTxn.abort();
|
||||||
} catch (abortErr) {
|
} catch (abortErr) {
|
||||||
console.error("Could not abort sync transaction, the sync response was probably only partially written and may have put storage in a inconsistent state.", abortErr);
|
console.error("Could not abort sync transaction, the sync response was probably only partially written and may have put storage in a inconsistent state.", abortErr);
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -18,10 +18,9 @@ import {encodeQueryParams} from "./common.js";
|
||||||
import {decryptAttachment} from "../e2ee/attachment.js";
|
import {decryptAttachment} from "../e2ee/attachment.js";
|
||||||
|
|
||||||
export class MediaRepository {
|
export class MediaRepository {
|
||||||
constructor({homeServer, crypto, request}) {
|
constructor({homeServer, platform}) {
|
||||||
this._homeServer = homeServer;
|
this._homeServer = homeServer;
|
||||||
this._crypto = crypto;
|
this._platform = platform;
|
||||||
this._request = request;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
mxcUrlThumbnail(url, width, height, method) {
|
mxcUrlThumbnail(url, width, height, method) {
|
||||||
|
@ -55,8 +54,8 @@ export class MediaRepository {
|
||||||
|
|
||||||
async downloadEncryptedFile(fileEntry) {
|
async downloadEncryptedFile(fileEntry) {
|
||||||
const url = this.mxcUrl(fileEntry.url);
|
const url = this.mxcUrl(fileEntry.url);
|
||||||
const {body: encryptedBuffer} = await this._request(url, {method: "GET", format: "buffer", cache: true}).response();
|
const {body: encryptedBuffer} = await this._platform.request(url, {method: "GET", format: "buffer", cache: true}).response();
|
||||||
const decryptedBuffer = await decryptAttachment(this._crypto, encryptedBuffer, fileEntry);
|
const decryptedBuffer = await decryptAttachment(this._platform.crypto, encryptedBuffer, fileEntry);
|
||||||
return decryptedBuffer;
|
return this._platform.createBufferHandle(decryptedBuffer, fileEntry.mimetype);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
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 {EventEntry} from "./timeline/entries/EventEntry.js";
|
||||||
import {EventKey} from "./timeline/EventKey.js";
|
import {EventKey} from "./timeline/EventKey.js";
|
||||||
import {Direction} from "./timeline/Direction.js";
|
import {Direction} from "./timeline/Direction.js";
|
||||||
|
import {ObservedEventMap} from "./ObservedEventMap.js";
|
||||||
import {DecryptionSource} from "../e2ee/common.js";
|
import {DecryptionSource} from "../e2ee/common.js";
|
||||||
|
|
||||||
const EVENT_ENCRYPTED_TYPE = "m.room.encrypted";
|
const EVENT_ENCRYPTED_TYPE = "m.room.encrypted";
|
||||||
|
|
||||||
export class Room extends EventEmitter {
|
export class Room extends EventEmitter {
|
||||||
|
@ -53,6 +53,7 @@ export class Room extends EventEmitter {
|
||||||
this._roomEncryption = null;
|
this._roomEncryption = null;
|
||||||
this._getSyncToken = getSyncToken;
|
this._getSyncToken = getSyncToken;
|
||||||
this._clock = clock;
|
this._clock = clock;
|
||||||
|
this._observedEvents = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
_readRetryDecryptCandidateEntries(sinceEventKey, txn) {
|
_readRetryDecryptCandidateEntries(sinceEventKey, txn) {
|
||||||
|
@ -165,6 +166,9 @@ export class Room extends EventEmitter {
|
||||||
}
|
}
|
||||||
await writeTxn.complete();
|
await writeTxn.complete();
|
||||||
decryption.applyToEntries(entries);
|
decryption.applyToEntries(entries);
|
||||||
|
if (this._observedEvents) {
|
||||||
|
this._observedEvents.updateEvents(entries);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
|
@ -285,6 +289,9 @@ export class Room extends EventEmitter {
|
||||||
if (this._timeline) {
|
if (this._timeline) {
|
||||||
this._timeline.appendLiveEntries(newTimelineEntries);
|
this._timeline.appendLiveEntries(newTimelineEntries);
|
||||||
}
|
}
|
||||||
|
if (this._observedEvents) {
|
||||||
|
this._observedEvents.updateEvents(newTimelineEntries);
|
||||||
|
}
|
||||||
if (removedPendingEvents) {
|
if (removedPendingEvents) {
|
||||||
this._sendQueue.emitRemovals(removedPendingEvents);
|
this._sendQueue.emitRemovals(removedPendingEvents);
|
||||||
}
|
}
|
||||||
|
@ -580,6 +587,45 @@ export class Room extends EventEmitter {
|
||||||
this._summary.applyChanges(changes);
|
this._summary.applyChanges(changes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
observeEvent(eventId) {
|
||||||
|
if (!this._observedEvents) {
|
||||||
|
this._observedEvents = new ObservedEventMap(() => {
|
||||||
|
this._observedEvents = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let entry = null;
|
||||||
|
if (this._timeline) {
|
||||||
|
entry = this._timeline.getByEventId(eventId);
|
||||||
|
}
|
||||||
|
const observable = this._observedEvents.observe(eventId, entry);
|
||||||
|
if (!entry) {
|
||||||
|
// update in the background
|
||||||
|
this._readEventById(eventId).then(entry => {
|
||||||
|
observable.update(entry);
|
||||||
|
}).catch(err => {
|
||||||
|
console.warn(`could not load event ${eventId} from storage`, err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return observable;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _readEventById(eventId) {
|
||||||
|
let stores = [this._storage.storeNames.timelineEvents];
|
||||||
|
if (this.isEncrypted) {
|
||||||
|
stores.push(this._storage.storeNames.inboundGroupSessions);
|
||||||
|
}
|
||||||
|
const txn = this._storage.readTxn(stores);
|
||||||
|
const storageEntry = await txn.timelineEvents.getByEventId(this._roomId, eventId);
|
||||||
|
if (storageEntry) {
|
||||||
|
const entry = new EventEntry(storageEntry, this._fragmentIdComparer);
|
||||||
|
if (entry.eventType === EVENT_ENCRYPTED_TYPE) {
|
||||||
|
const request = this._decryptEntries(DecryptionSource.Timeline, [entry], txn);
|
||||||
|
await request.complete();
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dispose() {
|
dispose() {
|
||||||
this._roomEncryption?.dispose();
|
this._roomEncryption?.dispose();
|
||||||
this._timeline?.dispose();
|
this._timeline?.dispose();
|
||||||
|
|
|
@ -95,6 +95,15 @@ export class Timeline {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getByEventId(eventId) {
|
||||||
|
for (let i = 0; i < this._remoteEntries.length; i += 1) {
|
||||||
|
const entry = this._remoteEntries.get(i);
|
||||||
|
if (entry.id === eventId) {
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
get entries() {
|
get entries() {
|
||||||
return this._allEntries;
|
return this._allEntries;
|
||||||
|
|
|
@ -48,6 +48,10 @@ export class BaseObservable {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get hasSubscriptions() {
|
||||||
|
return this._handlers.size !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
// Add iterator over handlers here
|
// Add iterator over handlers here
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -27,7 +27,7 @@ import {OnlineStatus} from "./dom/OnlineStatus.js";
|
||||||
import {Crypto} from "./dom/Crypto.js";
|
import {Crypto} from "./dom/Crypto.js";
|
||||||
import {estimateStorageUsage} from "./dom/StorageEstimate.js";
|
import {estimateStorageUsage} from "./dom/StorageEstimate.js";
|
||||||
import {WorkerPool} from "./dom/WorkerPool.js";
|
import {WorkerPool} from "./dom/WorkerPool.js";
|
||||||
import {BufferURL} from "./dom/BufferURL.js";
|
import {BufferHandle} from "./dom/BufferHandle.js";
|
||||||
|
|
||||||
function addScript(src) {
|
function addScript(src) {
|
||||||
return new Promise(function (resolve, reject) {
|
return new Promise(function (resolve, reject) {
|
||||||
|
@ -99,6 +99,8 @@ export class Platform {
|
||||||
} else {
|
} else {
|
||||||
this.request = xhrRequest;
|
this.request = xhrRequest;
|
||||||
}
|
}
|
||||||
|
const isIE11 = !!window.MSInputMethodContext && !!document.documentMode;
|
||||||
|
this.isIE11 = isIE11;
|
||||||
}
|
}
|
||||||
|
|
||||||
get updateService() {
|
get updateService() {
|
||||||
|
@ -116,8 +118,7 @@ export class Platform {
|
||||||
}
|
}
|
||||||
|
|
||||||
createAndMountRootView(vm) {
|
createAndMountRootView(vm) {
|
||||||
const isIE11 = !!window.MSInputMethodContext && !!document.documentMode;
|
if (this.isIE11) {
|
||||||
if (isIE11) {
|
|
||||||
this._container.className += " legacy";
|
this._container.className += " legacy";
|
||||||
}
|
}
|
||||||
window.__hydrogenViewModel = vm;
|
window.__hydrogenViewModel = vm;
|
||||||
|
@ -129,7 +130,7 @@ export class Platform {
|
||||||
this._serviceWorkerHandler?.setNavigation(navigation);
|
this._serviceWorkerHandler?.setNavigation(navigation);
|
||||||
}
|
}
|
||||||
|
|
||||||
createBufferURL(buffer, mimetype) {
|
createBufferHandle(buffer, mimetype) {
|
||||||
return new BufferURL(buffer, mimetype);
|
return new BufferHandle(buffer, mimetype);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -69,18 +69,27 @@ const ALLOWED_BLOB_MIMETYPES = {
|
||||||
'audio/x-flac': true,
|
'audio/x-flac': true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export class BufferURL {
|
export class BufferHandle {
|
||||||
constructor(buffer, mimetype) {
|
constructor(buffer, mimetype) {
|
||||||
mimetype = mimetype ? mimetype.split(";")[0].trim() : '';
|
mimetype = mimetype ? mimetype.split(";")[0].trim() : '';
|
||||||
if (!ALLOWED_BLOB_MIMETYPES[mimetype]) {
|
if (!ALLOWED_BLOB_MIMETYPES[mimetype]) {
|
||||||
mimetype = 'application/octet-stream';
|
mimetype = 'application/octet-stream';
|
||||||
}
|
}
|
||||||
const blob = new Blob([buffer], {type: mimetype});
|
this.blob = new Blob([buffer], {type: mimetype});
|
||||||
this.url = URL.createObjectURL(blob);
|
this._url = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get url() {
|
||||||
|
if (!this._url) {
|
||||||
|
this._url = URL.createObjectURL(this.blob);
|
||||||
|
}
|
||||||
|
return this._url;
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose() {
|
dispose() {
|
||||||
URL.revokeObjectURL(this.url);
|
if (this._url) {
|
||||||
this.url = null;
|
URL.revokeObjectURL(this._url);
|
||||||
|
this._url = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -19,6 +19,11 @@ html {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* unknown element in IE11 that defaults to inline */
|
||||||
|
main {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
@media screen and (min-width: 600px) {
|
@media screen and (min-width: 600px) {
|
||||||
.PreSessionScreen {
|
.PreSessionScreen {
|
||||||
width: 600px;
|
width: 600px;
|
||||||
|
@ -34,46 +39,60 @@ html {
|
||||||
}
|
}
|
||||||
|
|
||||||
.SessionView {
|
.SessionView {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
/* this takes into account whether or not the url bar is hidden on mobile
|
/* this takes into account whether or not the url bar is hidden on mobile
|
||||||
(have tested Firefox Android and Safari on iOS),
|
(have tested Firefox Android and Safari on iOS),
|
||||||
see https://developers.google.com/web/updates/2016/12/url-bar-resizing */
|
see https://developers.google.com/web/updates/2016/12/url-bar-resizing */
|
||||||
position: fixed;
|
position: fixed;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
.SessionView > .main {
|
grid-template:
|
||||||
flex: 1;
|
"status status" auto
|
||||||
display: flex;
|
"left middle" 1fr /
|
||||||
|
300px 1fr;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
width: 100vw;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* hide back button in middle section by default */
|
/* hide back button in middle section by default */
|
||||||
.middle .close-middle { display: none; }
|
.middle .close-middle { display: none; }
|
||||||
/* mobile layout */
|
/* mobile layout */
|
||||||
@media screen and (max-width: 800px) {
|
@media screen and (max-width: 800px) {
|
||||||
|
.SessionView:not(.middle-shown) {
|
||||||
|
grid-template:
|
||||||
|
"status" auto
|
||||||
|
"left" 1fr /
|
||||||
|
1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.SessionView.middle-shown {
|
||||||
|
grid-template:
|
||||||
|
"status" auto
|
||||||
|
"middle" 1fr /
|
||||||
|
1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.SessionView:not(.middle-shown) .room-placeholder { display: none; }
|
||||||
|
.SessionView.middle-shown .LeftPanel { display: none; }
|
||||||
|
|
||||||
/* show back button */
|
/* show back button */
|
||||||
.middle .close-middle { display: block !important; }
|
.middle .close-middle { display: block !important; }
|
||||||
/* hide grid button */
|
/* hide grid button */
|
||||||
.LeftPanel .grid { display: none !important; }
|
.LeftPanel .grid { display: none !important; }
|
||||||
div.middle, div.room-placeholder { display: none; }
|
|
||||||
div.LeftPanel {flex-grow: 1;}
|
|
||||||
div.middle-shown div.middle { display: flex; }
|
|
||||||
div.middle-shown div.LeftPanel { display: none; }
|
|
||||||
div.right-shown div.TimelinePanel { display: none; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.LeftPanel {
|
.LeftPanel {
|
||||||
flex: 0 0 300px;
|
grid-area: left;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.room-placeholder, .middle {
|
.room-placeholder, .middle {
|
||||||
flex: 1 0 0;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
grid-area: middle;
|
||||||
|
/* when room view is inside of a grid,
|
||||||
|
grid-area middle won't be found,
|
||||||
|
so set width manually */
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.RoomView {
|
.RoomView {
|
||||||
|
@ -81,6 +100,19 @@ html {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.SessionStatusView {
|
||||||
|
grid-area: status;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox {
|
||||||
|
/* cover left and middle panel, not status view
|
||||||
|
use numeric positions because named grid areas
|
||||||
|
are not present in mobile layout */
|
||||||
|
grid-area: 2 / 1 / 3 / 3;
|
||||||
|
/* this should not be necessary, but chrome seems to have a bug when there are scrollbars in other grid items,
|
||||||
|
it seems to put the scroll areas on top of the other grid items unless they have a z-index */
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.TimelinePanel {
|
.TimelinePanel {
|
||||||
flex: 3;
|
flex: 3;
|
||||||
|
|
|
@ -494,6 +494,8 @@ ul.Timeline > li.messageStatus .message-container > p {
|
||||||
.message-container {
|
.message-container {
|
||||||
padding: 1px 10px 0px 10px;
|
padding: 1px 10px 0px 10px;
|
||||||
margin: 5px 10px 0 10px;
|
margin: 5px 10px 0 10px;
|
||||||
|
/* so the .picture can grow horizontally and its spacer can grow vertically */
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-container .profile {
|
.message-container .profile {
|
||||||
|
@ -505,9 +507,8 @@ ul.Timeline > li.messageStatus .message-container > p {
|
||||||
--avatar-size: 25px;
|
--avatar-size: 25px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-container img.picture {
|
.TextMessageView {
|
||||||
margin-top: 4px;
|
width: 100%;
|
||||||
border-radius: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.TextMessageView.continuation .message-container {
|
.TextMessageView.continuation .message-container {
|
||||||
|
@ -538,6 +539,46 @@ ul.Timeline > li.messageStatus .message-container > p {
|
||||||
color: #aaa;
|
color: #aaa;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.message-container .picture {
|
||||||
|
display: grid;
|
||||||
|
text-decoration: none;
|
||||||
|
margin-top: 4px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* .spacer grows with an inline padding-top to the size of the image,
|
||||||
|
so the timeline doesn't jump when the image loads */
|
||||||
|
.message-container .picture > * {
|
||||||
|
grid-row: 1;
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-container .picture > img {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
/* for IE11 to still scale even though the spacer is too tall */
|
||||||
|
align-self: start;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-container .picture > time {
|
||||||
|
align-self: end;
|
||||||
|
justify-self: end;
|
||||||
|
color: #2e2f32;
|
||||||
|
display: block;
|
||||||
|
padding: 2px;
|
||||||
|
margin: 4px;
|
||||||
|
background-color: rgba(255, 255, 255, 0.75);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.message-container .picture > .spacer {
|
||||||
|
/* TODO: can we implement this with a pseudo element? or perhaps they are not grid items? */
|
||||||
|
width: 100%;
|
||||||
|
/* don't stretch height as it is a spacer, just in case it doesn't match with image height */
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
.TextMessageView.pending .message-container {
|
.TextMessageView.pending .message-container {
|
||||||
color: #ccc;
|
color: #ccc;
|
||||||
}
|
}
|
||||||
|
@ -632,3 +673,72 @@ button.link {
|
||||||
color: #03B381;
|
color: #03B381;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lightbox {
|
||||||
|
background-color: rgba(0,0,0,0.75);
|
||||||
|
display: grid;
|
||||||
|
grid-template:
|
||||||
|
"content close" auto
|
||||||
|
"content details" 1fr /
|
||||||
|
1fr auto;
|
||||||
|
color: white;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-aspect-ratio: 1/1) {
|
||||||
|
.lightbox {
|
||||||
|
grid-template:
|
||||||
|
"close" auto
|
||||||
|
"content" 1fr
|
||||||
|
"details" auto /
|
||||||
|
1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox .details {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox .picture {
|
||||||
|
grid-area: content;
|
||||||
|
background-size: contain;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
align-self: center;
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox .loading {
|
||||||
|
grid-area: content;
|
||||||
|
align-self: center;
|
||||||
|
justify-self: center;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox .loading > :not(:first-child) {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox .close {
|
||||||
|
display: block;
|
||||||
|
grid-area: close;
|
||||||
|
justify-self: end;
|
||||||
|
background-image: url('icons/dismiss.svg');
|
||||||
|
background-position: center;
|
||||||
|
background-size: 16px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox .details {
|
||||||
|
grid-area: details;
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -37,23 +37,12 @@ limitations under the License.
|
||||||
margin: 5px 0;
|
margin: 5px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-container a {
|
.message-container .picture {
|
||||||
display: block;
|
display: block;
|
||||||
position: relative;
|
|
||||||
max-width: 100%;
|
|
||||||
/* width and padding-top set inline to maintain aspect ratio,
|
|
||||||
replace with css aspect-ratio once supported */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-container img.picture {
|
.message-container .picture > img {
|
||||||
display: block;
|
display: block;
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.TextMessageView {
|
.TextMessageView {
|
||||||
|
|
|
@ -74,16 +74,16 @@ export class TemplateView {
|
||||||
|
|
||||||
_attach() {
|
_attach() {
|
||||||
if (this._eventListeners) {
|
if (this._eventListeners) {
|
||||||
for (let {node, name, fn} of this._eventListeners) {
|
for (let {node, name, fn, useCapture} of this._eventListeners) {
|
||||||
node.addEventListener(name, fn);
|
node.addEventListener(name, fn, useCapture);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_detach() {
|
_detach() {
|
||||||
if (this._eventListeners) {
|
if (this._eventListeners) {
|
||||||
for (let {node, name, fn} of this._eventListeners) {
|
for (let {node, name, fn, useCapture} of this._eventListeners) {
|
||||||
node.removeEventListener(name, fn);
|
node.removeEventListener(name, fn, useCapture);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -132,11 +132,11 @@ export class TemplateView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_addEventListener(node, name, fn) {
|
_addEventListener(node, name, fn, useCapture = false) {
|
||||||
if (!this._eventListeners) {
|
if (!this._eventListeners) {
|
||||||
this._eventListeners = [];
|
this._eventListeners = [];
|
||||||
}
|
}
|
||||||
this._eventListeners.push({node, name, fn});
|
this._eventListeners.push({node, name, fn, useCapture});
|
||||||
}
|
}
|
||||||
|
|
||||||
_addBinding(bindingFn) {
|
_addBinding(bindingFn) {
|
||||||
|
@ -164,6 +164,10 @@ class TemplateBuilder {
|
||||||
return this._templateView._value;
|
return this._templateView._value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addEventListener(node, name, fn, useCapture = false) {
|
||||||
|
this._templateView._addEventListener(node, name, fn, useCapture);
|
||||||
|
}
|
||||||
|
|
||||||
_addAttributeBinding(node, name, fn) {
|
_addAttributeBinding(node, name, fn) {
|
||||||
let prevValue = undefined;
|
let prevValue = undefined;
|
||||||
const binding = () => {
|
const binding = () => {
|
||||||
|
|
|
@ -35,7 +35,7 @@ export class LoginView extends TemplateView {
|
||||||
});
|
});
|
||||||
const homeserver = t.input({
|
const homeserver = t.input({
|
||||||
id: "homeserver",
|
id: "homeserver",
|
||||||
type: "text",
|
type: "url",
|
||||||
placeholder: vm.i18n`Your matrix homeserver`,
|
placeholder: vm.i18n`Your matrix homeserver`,
|
||||||
value: vm.defaultHomeServer,
|
value: vm.defaultHomeServer,
|
||||||
disabled
|
disabled
|
||||||
|
|
|
@ -17,6 +17,7 @@ limitations under the License.
|
||||||
|
|
||||||
import {LeftPanelView} from "./leftpanel/LeftPanelView.js";
|
import {LeftPanelView} from "./leftpanel/LeftPanelView.js";
|
||||||
import {RoomView} from "./room/RoomView.js";
|
import {RoomView} from "./room/RoomView.js";
|
||||||
|
import {LightboxView} from "./room/LightboxView.js";
|
||||||
import {TemplateView} from "../general/TemplateView.js";
|
import {TemplateView} from "../general/TemplateView.js";
|
||||||
import {StaticView} from "../general/StaticView.js";
|
import {StaticView} from "../general/StaticView.js";
|
||||||
import {SessionStatusView} from "./SessionStatusView.js";
|
import {SessionStatusView} from "./SessionStatusView.js";
|
||||||
|
@ -32,21 +33,20 @@ export class SessionView extends TemplateView {
|
||||||
},
|
},
|
||||||
}, [
|
}, [
|
||||||
t.view(new SessionStatusView(vm.sessionStatusViewModel)),
|
t.view(new SessionStatusView(vm.sessionStatusViewModel)),
|
||||||
t.div({className: "main"}, [
|
t.view(new LeftPanelView(vm.leftPanelViewModel)),
|
||||||
t.view(new LeftPanelView(vm.leftPanelViewModel)),
|
t.mapView(vm => vm.activeSection, activeSection => {
|
||||||
t.mapView(vm => vm.activeSection, activeSection => {
|
switch (activeSection) {
|
||||||
switch (activeSection) {
|
case "roomgrid":
|
||||||
case "roomgrid":
|
return new RoomGridView(vm.roomGridViewModel);
|
||||||
return new RoomGridView(vm.roomGridViewModel);
|
case "placeholder":
|
||||||
case "placeholder":
|
return new StaticView(t => t.div({className: "room-placeholder"}, t.h2(vm.i18n`Choose a room on the left side.`)));
|
||||||
return new StaticView(t => t.div({className: "room-placeholder"}, t.h2(vm.i18n`Choose a room on the left side.`)));
|
case "settings":
|
||||||
case "settings":
|
return new SettingsView(vm.settingsViewModel);
|
||||||
return new SettingsView(vm.settingsViewModel);
|
default: //room id
|
||||||
default: //room id
|
return new RoomView(vm.currentRoomViewModel);
|
||||||
return new RoomView(vm.currentRoomViewModel);
|
}
|
||||||
}
|
}),
|
||||||
})
|
t.mapView(vm => vm.lightboxViewModel, lightboxViewModel => lightboxViewModel ? new LightboxView(lightboxViewModel) : null)
|
||||||
])
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
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 {
|
export class ImageView extends TemplateView {
|
||||||
render(t, vm) {
|
render(t, vm) {
|
||||||
// replace with css aspect-ratio once supported
|
const heightRatioPercent = (vm.thumbnailHeight / vm.thumbnailWidth) * 100;
|
||||||
const heightRatioPercent = (vm.thumbnailHeight / vm.thumbnailWidth) * 100;
|
let spacerStyle = `padding-top: ${heightRatioPercent}%;`;
|
||||||
const image = t.img({
|
if (vm.platform.isIE11) {
|
||||||
className: "picture",
|
// preserving aspect-ratio in a grid with padding percentages
|
||||||
src: vm => vm.thumbnailUrl,
|
// does not work in IE11, so we assume people won't use it
|
||||||
width: vm.thumbnailWidth,
|
// with viewports narrower than 400px where thumbnails will get
|
||||||
height: vm.thumbnailHeight,
|
// scaled. If they do, the thumbnail will still scale, but
|
||||||
loading: "lazy",
|
// there will be whitespace underneath the picture
|
||||||
alt: vm => vm.label,
|
// An alternative would be to use position: absolute but that
|
||||||
title: vm => vm.label,
|
// can slow down rendering, and was bleeding through the lightbox.
|
||||||
});
|
spacerStyle = `height: ${vm.thumbnailHeight}px`;
|
||||||
const linkContainer = t.a({
|
}
|
||||||
style: `padding-top: ${heightRatioPercent}%; width: ${vm.thumbnailWidth}px;`
|
return renderMessage(t, vm, [
|
||||||
}, [
|
t.a({href: vm.lightboxUrl, className: "picture", style: `max-width: ${vm.thumbnailWidth}px`}, [
|
||||||
image,
|
t.div({className: "spacer", style: spacerStyle}),
|
||||||
|
t.img({
|
||||||
|
loading: "lazy",
|
||||||
|
src: vm => vm.thumbnailUrl,
|
||||||
|
alt: vm => vm.label,
|
||||||
|
title: vm => vm.label,
|
||||||
|
style: `max-width: ${vm.thumbnailWidth}px; max-height: ${vm.thumbnailHeight}px;`
|
||||||
|
}),
|
||||||
|
t.time(vm.date + " " + vm.time),
|
||||||
|
]),
|
||||||
t.if(vm => vm.error, t.createTemplate((t, vm) => t.p({className: "error"}, vm.error)))
|
t.if(vm => vm.error, t.createTemplate((t, vm) => t.p({className: "error"}, vm.error)))
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return renderMessage(t, vm,
|
|
||||||
[t.div(linkContainer), t.p(t.time(vm.date + " " + vm.time))]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Reference in a new issue