diff --git a/src/domain/session/RoomViewModelObservable.js b/src/domain/session/RoomViewModelObservable.js index fb2fc50200..e11b5c9cb5 100644 --- a/src/domain/session/RoomViewModelObservable.js +++ b/src/domain/session/RoomViewModelObservable.js @@ -77,10 +77,24 @@ export class RoomViewModelObservable extends ObservableValue { } else if (status & RoomStatus.Archived) { return await this._sessionViewModel._createArchivedRoomViewModel(this.id); } else { - return this._sessionViewModel._createUnknownRoomViewModel(this.id); + return this._sessionViewModel._createUnknownRoomViewModel(this.id, this._isWorldReadablePromise()); } } + async _isWorldReadablePromise() { + const {session} = this._sessionViewModel._client; + const isWorldReadable = await session.isWorldReadableRoom(this.id); + if (isWorldReadable) { + const vm = await this._sessionViewModel._createWorldReadableRoomViewModel(this.id); + if (vm) { + this.get()?.dispose(); + this.set(vm); + return true; + } + } + return false; + } + dispose() { if (this._statusSubscription) { this._statusSubscription = this._statusSubscription(); diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 7d1dac3ce6..13e63e2b1d 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -18,6 +18,7 @@ limitations under the License. import {LeftPanelViewModel} from "./leftpanel/LeftPanelViewModel.js"; import {RoomViewModel} from "./room/RoomViewModel.js"; import {UnknownRoomViewModel} from "./room/UnknownRoomViewModel.js"; +import {WorldReadableRoomViewModel} from "./room/WorldReadableRoomViewModel.js"; import {InviteViewModel} from "./room/InviteViewModel.js"; import {RoomBeingCreatedViewModel} from "./room/RoomBeingCreatedViewModel.js"; import {LightboxViewModel} from "./room/LightboxViewModel.js"; @@ -231,13 +232,23 @@ export class SessionViewModel extends ViewModel { return null; } - _createUnknownRoomViewModel(roomIdOrAlias) { + _createUnknownRoomViewModel(roomIdOrAlias, isWorldReadablePromise) { return new UnknownRoomViewModel(this.childOptions({ roomIdOrAlias, session: this._client.session, + isWorldReadablePromise: isWorldReadablePromise })); } + async _createWorldReadableRoomViewModel(roomIdOrAlias) { + const roomVM = new WorldReadableRoomViewModel(this.childOptions({ + room: await this._client.session.loadWorldReadableRoom(roomIdOrAlias), + session: this._client.session, + })); + roomVM.load(); + return roomVM; + } + async _createArchivedRoomViewModel(roomId) { const room = await this._client.session.loadArchivedRoom(roomId); if (room) { diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 31608a62e7..e35c7bcca0 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -41,7 +41,7 @@ export class RoomViewModel extends ErrorReportViewModel { this._composerVM = null; if (room.isArchived) { this._composerVM = this.track(new ArchivedViewModel(this.childOptions({archivedRoom: room}))); - } else { + } else if (!room.isWorldReadable) { this._recreateComposerOnPowerLevelChange(); } this._clearUnreadTimout = null; @@ -218,7 +218,7 @@ export class RoomViewModel extends ErrorReportViewModel { } } } - + _sendMessage(message, replyingTo) { return this.logAndCatch("RoomViewModel.sendMessage", async log => { let success = false; diff --git a/src/domain/session/room/UnknownRoomViewModel.js b/src/domain/session/room/UnknownRoomViewModel.js index 8bb5fb0af9..dc7ca34002 100644 --- a/src/domain/session/room/UnknownRoomViewModel.js +++ b/src/domain/session/room/UnknownRoomViewModel.js @@ -19,11 +19,17 @@ import {ViewModel} from "../../ViewModel"; export class UnknownRoomViewModel extends ViewModel { constructor(options) { super(options); - const {roomIdOrAlias, session} = options; + const {roomIdOrAlias, session, isWorldReadablePromise} = options; this._session = session; this.roomIdOrAlias = roomIdOrAlias; this._error = null; this._busy = false; + + this.checkingPreviewCapability = true; + isWorldReadablePromise.then(() => { + this.checkingPreviewCapability = false; + this.emitChange('checkingPreviewCapability'); + }) } get error() { diff --git a/src/domain/session/room/WorldReadableRoomViewModel.js b/src/domain/session/room/WorldReadableRoomViewModel.js new file mode 100644 index 0000000000..88ddede7b8 --- /dev/null +++ b/src/domain/session/room/WorldReadableRoomViewModel.js @@ -0,0 +1,47 @@ +import {RoomViewModel} from "./RoomViewModel"; + +export class WorldReadableRoomViewModel extends RoomViewModel { + constructor(options) { + options.room.isWorldReadable = true; + super(options); + this._room = options.room; + this._session = options.session; + this._error = null; + this._busy = false; + } + + get kind() { + return "preview"; + } + + get busy() { + return this._busy; + } + + async join() { + this._busy = true; + this.emitChange("busy"); + try { + const roomId = await this._session.joinRoom(this._room.id); + // navigate to roomId if we were at the alias + // so we're subscribed to the right room status + // and we'll switch to the room view model once + // the join is synced + this.navigation.push("room", roomId); + // keep busy on true while waiting for the join to sync + } catch (err) { + this._error = err; + this._busy = false; + this.emitChange("error"); + } + } + + dispose() { + super.dispose(); + + // if joining the room, _busy would be true and in that case don't delete records + if (!this._busy) { + void this._session.deleteWorldReadableRoomData(this._room.id); + } + } +} diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 35f713f658..bd5824356f 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -49,6 +49,8 @@ import {SecretStorage} from "./ssss/SecretStorage"; import {ObservableValue, RetainedObservableValue} from "../observable/value"; import {CallHandler} from "./calls/CallHandler"; import {RoomStateHandlerSet} from "./room/state/RoomStateHandlerSet"; +import {EventKey} from "./room/timeline/EventKey"; +import {createEventEntry} from "./room/timeline/persistence/common"; const PICKLE_KEY = "DEFAULT_KEY"; const PUSHER_KEY = "pusher"; @@ -655,6 +657,22 @@ export class Session { return room; } + /** @internal */ + _createWorldReadableRoom(roomId) { + return new Room({ + roomId, + getSyncToken: this._getSyncToken, + storage: this._storage, + emitCollectionChange: this._roomUpdateCallback, + hsApi: this._hsApi, + mediaRepository: this._mediaRepository, + pendingEvents: [], + user: this._user, + platform: this._platform, + roomStateHandler: this._roomStateHandler + }); + } + get invites() { return this._invites; } @@ -1031,12 +1049,135 @@ export class Session { }); } + loadWorldReadableRoom(roomId, log = null) { + return this._platform.logger.wrapOrRun(log, "loadWorldReadableRoom", async log => { + log.set("id", roomId); + + const room = this._createWorldReadableRoom(roomId); + let response = await this._fetchWorldReadableRoomEvents(roomId, 100, 'b', null, log); + // Note: response.end to be used in the next call for sync functionality + + let summary = await this._prepareWorldReadableRoomSummary(roomId, log); + const txn = await this._storage.readTxn([ + this._storage.storeNames.timelineFragments, + this._storage.storeNames.timelineEvents, + this._storage.storeNames.roomMembers, + ]); + await room.load(summary, txn, log); + + return room; + }); + } + + async _prepareWorldReadableRoomSummary(roomId, log = null) { + return this._platform.logger.wrapOrRun(log, "prepareWorldReadableRoomSummary", async log => { + log.set("id", roomId); + + let summary = {}; + const resp = await this._hsApi.currentState(roomId).response(); + for ( let i=0; i { + log.set("id", roomId); + let options = { + limit: limit, + dir: 'b', + filter: { + lazy_load_members: true, + include_redundant_members: true, + } + } + if (end !== null) { + options['from'] = end; + } + + const response = await this._hsApi.messages(roomId, options, {log}).response(); + log.set("/messages endpoint response", response); + + await this.deleteWorldReadableRoomData(roomId, log); + + const txn = await this._storage.readWriteTxn([ + this._storage.storeNames.timelineFragments, + this._storage.storeNames.timelineEvents, + ]); + + // insert fragment and event records for this room + const fragment = { + roomId: roomId, + id: 0, + previousId: null, + nextId: null, + previousToken: response.start, + nextToken: null, + }; + txn.timelineFragments.add(fragment); + + let eventKey = EventKey.defaultLiveKey; + for (let i = 0; i < response.chunk.length; i++) { + if (i) { + eventKey = eventKey.previousKey(); + } + let txn = await this._storage.readWriteTxn([this._storage.storeNames.timelineEvents]); + let eventEntry = createEventEntry(eventKey, roomId, response.chunk[i]); + await txn.timelineEvents.tryInsert(eventEntry, log); + } + + return response; + }); + } + + async deleteWorldReadableRoomData(roomId, log = null) { + return this._platform.logger.wrapOrRun(log, "deleteWorldReadableRoomData", async log => { + log.set("id", roomId); + + const txn = await this._storage.readWriteTxn([ + this._storage.storeNames.timelineFragments, + this._storage.storeNames.timelineEvents, + ]); + + // clear old records for this room + txn.timelineFragments.removeAllForRoom(roomId); + txn.timelineEvents.removeAllForRoom(roomId); + }); + } + joinRoom(roomIdOrAlias, log = null) { return this._platform.logger.wrapOrRun(log, "joinRoom", async log => { const body = await this._hsApi.joinIdOrAlias(roomIdOrAlias, {log}).response(); return body.room_id; }); } + + async isWorldReadableRoom(roomIdOrAlias, log = null) { + return this._platform.logger.wrapOrRun(log, "isWorldReadableRoom", async log => { + try { + let roomId; + if (!roomIdOrAlias.startsWith("!")) { + let response = await this._hsApi.resolveRoomAlias(roomIdOrAlias).response(); + roomId = response.room_id; + } else { + roomId = roomIdOrAlias; + } + const body = await this._hsApi.state(roomId, 'm.room.history_visibility', '', {log}).response(); + return body.history_visibility === 'world_readable'; + } catch { + return false; + } + }); + } } import {FeatureSet} from "../features"; diff --git a/src/matrix/net/HomeServerApi.ts b/src/matrix/net/HomeServerApi.ts index c5f9055504..8f168e6693 100644 --- a/src/matrix/net/HomeServerApi.ts +++ b/src/matrix/net/HomeServerApi.ts @@ -129,6 +129,10 @@ export class HomeServerApi { return this._get("/sync", {since, timeout, filter}, undefined, options); } + resolveRoomAlias(roomAlias: string): IHomeServerRequest { + return this._unauthedRequest( "GET", this._url( `/directory/room/${encodeURIComponent(roomAlias)}`, CS_V3_PREFIX ) ); + } + context(roomId: string, eventId: string, limit: number, filter: string): IHomeServerRequest { return this._get(`/rooms/${encodeURIComponent(roomId)}/context/${encodeURIComponent(eventId)}`, {filter, limit}); } @@ -164,6 +168,10 @@ export class HomeServerApi { return this._put(`/rooms/${encodeURIComponent(roomId)}/state/${encodeURIComponent(eventType)}/${encodeURIComponent(stateKey)}`, {}, content, options); } + currentState(roomId: string): IHomeServerRequest { + return this._get(`/rooms/${encodeURIComponent(roomId)}/state`, {}, undefined); + } + getLoginFlows(): IHomeServerRequest { return this._unauthedRequest("GET", this._url("/login")); } diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 4c617386fa..0e7dff12db 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -975,7 +975,7 @@ button.link { width: 100%; } -.DisabledComposerView { +.DisabledComposerView, .WorldReadableRoomComposerView { padding: 12px; background-color: var(--background-color-secondary); } @@ -1002,6 +1002,32 @@ button.link { width: 100%; } +.UnknownRoomView .checkingPreviewCapability { + display: flex; + flex-direction: row; /* make main axis vertical */ + justify-content: center; /* center items vertically, in this case */ + align-items: center; /* center items horizontally, in this case */ + margin-top: 5px; +} + +.UnknownRoomView .checkingPreviewCapability p { + margin-left: 5px; +} + +.WorldReadableRoomView .Timeline_message:hover > .Timeline_messageOptions{ + display: none; +} +.WorldReadableRoomView .Timeline_messageAvatar { + pointer-events: none; /* Prevent user panel from opening when clicking on avatars in the timeline. */ +} +.WorldReadableRoomComposerView h3 { + display: inline-block; + margin: 0; +} +.WorldReadableRoomComposerView .joinRoomButton { + float: right; +} + .LoadingView { height: 100%; width: 100%; diff --git a/src/platform/web/ui/session/SessionView.js b/src/platform/web/ui/session/SessionView.js index 9f84e872ad..e224fe5225 100644 --- a/src/platform/web/ui/session/SessionView.js +++ b/src/platform/web/ui/session/SessionView.js @@ -18,6 +18,7 @@ limitations under the License. import {LeftPanelView} from "./leftpanel/LeftPanelView.js"; import {RoomView} from "./room/RoomView.js"; import {UnknownRoomView} from "./room/UnknownRoomView.js"; +import {WorldReadableRoomView} from "./room/WorldReadableRoomView.js"; import {RoomBeingCreatedView} from "./room/RoomBeingCreatedView.js"; import {InviteView} from "./room/InviteView.js"; import {LightboxView} from "./room/LightboxView.js"; @@ -60,6 +61,8 @@ export class SessionView extends TemplateView { return new RoomView(vm.currentRoomViewModel, viewClassForTile); } else if (vm.currentRoomViewModel.kind === "roomBeingCreated") { return new RoomBeingCreatedView(vm.currentRoomViewModel); + } else if (vm.currentRoomViewModel.kind === "preview") { + return new WorldReadableRoomView(vm.currentRoomViewModel); } else { return new UnknownRoomView(vm.currentRoomViewModel); } diff --git a/src/platform/web/ui/session/room/UnknownRoomView.js b/src/platform/web/ui/session/room/UnknownRoomView.js index 80d857d801..a9ae7b9e61 100644 --- a/src/platform/web/ui/session/room/UnknownRoomView.js +++ b/src/platform/web/ui/session/room/UnknownRoomView.js @@ -15,6 +15,7 @@ limitations under the License. */ import {TemplateView} from "../../general/TemplateView"; +import {spinner} from "../../common.js"; export class UnknownRoomView extends TemplateView { render(t, vm) { @@ -29,6 +30,11 @@ export class UnknownRoomView extends TemplateView { onClick: () => vm.join(), disabled: vm => vm.busy, }, vm.i18n`Join room`), + t.br(), + t.if(vm => vm.checkingPreviewCapability, t => t.div({className: "checkingPreviewCapability"}, [ + spinner(t), + t.p(vm.i18n`Checking preview capability...`) + ])), t.if(vm => vm.error, t => t.p({className: "error"}, vm.error)) ])); } diff --git a/src/platform/web/ui/session/room/WorldReadableRoomView.js b/src/platform/web/ui/session/room/WorldReadableRoomView.js new file mode 100644 index 0000000000..092e7ae075 --- /dev/null +++ b/src/platform/web/ui/session/room/WorldReadableRoomView.js @@ -0,0 +1,44 @@ +import {TemplateView} from "../../general/TemplateView"; +import {TimelineView} from "./TimelineView"; +import {viewClassForTile} from "./common"; +import {TimelineLoadingView} from "./TimelineLoadingView"; +import {AvatarView} from "../../AvatarView"; + +export class WorldReadableRoomView extends TemplateView { + + constructor(vm) { + super(vm); + } + + render(t, vm) { + return t.div({className: "RoomView WorldReadableRoomView middle"}, [ + t.div({className: "RoomHeader middle-header"}, [ + t.view(new AvatarView(vm, 32)), + t.div({className: "room-description"}, [ + t.h2(vm => vm.room.name), + ]), + ]), + t.div({className: "RoomView_body"}, [ + t.div({className: "RoomView_error"}, [ + t.if(vm => vm.error, t => t.div([ + t.p({}, vm => vm.error), + t.button({className: "RoomView_error_closerButton", onClick: evt => vm.dismissError(evt)}) + ])) + ]), + t.mapView(vm => vm.timelineViewModel, timelineViewModel => { + return timelineViewModel ? + new TimelineView(timelineViewModel, viewClassForTile) : + new TimelineLoadingView(vm); // vm is just needed for i18n + }), + t.div({className: "WorldReadableRoomComposerView"}, [ + t.h3(vm => vm.i18n`Join the room to participate`), + t.button({ + className: "joinRoomButton", + onClick: () => vm.join(), + disabled: vm => vm.busy, + }, vm.i18n`Join Room`) + ]) + ]) + ]); + } +}