diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 7d1dac3ce6..5b762c17cb 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -231,11 +231,13 @@ export class SessionViewModel extends ViewModel { return null; } - _createUnknownRoomViewModel(roomIdOrAlias) { - return new UnknownRoomViewModel(this.childOptions({ + async _createUnknownRoomViewModel(roomIdOrAlias) { + const roomVM = new UnknownRoomViewModel(this.childOptions({ roomIdOrAlias, session: this._client.session, })); + void roomVM.load(); + return roomVM; } async _createArchivedRoomViewModel(roomId) { diff --git a/src/domain/session/room/UnknownRoomViewModel.js b/src/domain/session/room/UnknownRoomViewModel.js index 8bb5fb0af9..7019277431 100644 --- a/src/domain/session/room/UnknownRoomViewModel.js +++ b/src/domain/session/room/UnknownRoomViewModel.js @@ -15,6 +15,9 @@ limitations under the License. */ import {ViewModel} from "../../ViewModel"; +import {TimelineViewModel} from "./timeline/TimelineViewModel"; +import {tileClassForEntry as defaultTileClassForEntry} from "./timeline/tiles/index"; +import {getAvatarHttpUrl} from "../../avatar"; export class UnknownRoomViewModel extends ViewModel { constructor(options) { @@ -24,6 +27,12 @@ export class UnknownRoomViewModel extends ViewModel { this.roomIdOrAlias = roomIdOrAlias; this._error = null; this._busy = false; + this._worldReadable = false; // won't know until load() finishes with isWorldReadableRoom() call + this._checkingPreviewCapability = false; // won't know until load() finishes with isWorldReadableRoom() call + } + + get room() { + return this._room; } get error() { @@ -52,7 +61,49 @@ export class UnknownRoomViewModel extends ViewModel { return this._busy; } + get checkingPreviewCapability() { + return this._checkingPreviewCapability; + } + get kind() { - return "unknown"; + return this._worldReadable ? "worldReadableRoom" : "unknown"; + } + + get timelineViewModel() { + return this._timelineVM; + } + + avatarUrl(size) { + return getAvatarHttpUrl(this._room.avatarUrl, size, this.platform, this._room.mediaRepository); + } + + async load() { + this._checkingPreviewCapability = true; + this._worldReadable = await this._session.isWorldReadableRoom(this.roomIdOrAlias); + this._checkingPreviewCapability = false; + + if (!this._worldReadable) { + this.emitChange("checkingPreviewCapability"); + return; + } + + try { + this._room = await this._session.loadWorldReadableRoom(this.roomIdOrAlias); + const timeline = await this._room.openTimeline(); + this._tileOptions = this.childOptions({ + roomVM: this, + timeline, + tileClassForEntry: defaultTileClassForEntry, + }); + this._timelineVM = this.track(new TimelineViewModel(this.childOptions({ + tileOptions: this._tileOptions, + timeline, + }))); + this.emitChange("timelineViewModel"); + } catch (err) { + console.error(`room.openTimeline(): ${err.message}:\n${err.stack}`); + this._timelineError = err; + this.emitChange("error"); + } } } diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 35f713f658..a0397567c0 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,21 @@ 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 + }); + } + get invites() { return this._invites; } @@ -1031,12 +1048,122 @@ 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); + + 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); + + // 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; + }); + } + 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; }); } + + 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/room/UnknownRoomView.js b/src/platform/web/ui/session/room/UnknownRoomView.js index 80d857d801..cd25300d6b 100644 --- a/src/platform/web/ui/session/room/UnknownRoomView.js +++ b/src/platform/web/ui/session/room/UnknownRoomView.js @@ -14,22 +14,78 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../../general/TemplateView"; +import {InlineTemplateView, TemplateView} from "../../general/TemplateView"; +import {AvatarView} from "../../AvatarView"; +import {TimelineView} from "./TimelineView"; +import {TimelineLoadingView} from "./TimelineLoadingView"; +import {spinner} from "../../common.js"; +import {viewClassForTile} from "./common"; export class UnknownRoomView extends TemplateView { + + constructor(vm) { + super(vm); + } + + render(t, vm) { + return t.mapView(vm => vm.kind, kind => { + const unknownRoomView = new InlineTemplateView(vm, (t, m) => { + return t.main({className: "UnknownRoomView middle"}, t.div([ + t.h2([ + vm.i18n`You are currently not in ${vm.roomIdOrAlias}.`, + t.br(), + vm.i18n`Want to join it?` + ]), + t.button({ + className: "button-action primary", + 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)) + ])); + }); + return kind === 'worldReadableRoom' ? new WorldReadableRoomView(vm) : unknownRoomView; + }); + } +} + +class WorldReadableRoomView extends InlineTemplateView { + + constructor(value, render) { + super(value, render); + } + render(t, vm) { - return t.main({className: "UnknownRoomView middle"}, t.div([ - t.h2([ - vm.i18n`You are currently not in ${vm.roomIdOrAlias}.`, - t.br(), - vm.i18n`Want to join it?` + return t.main({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.button({ - className: "button-action primary", - onClick: () => vm.join(), - disabled: vm => vm.busy, - }, vm.i18n`Join room`), - t.if(vm => vm.error, t => t.p({className: "error"}, vm.error)) - ])); + 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()}, vm.i18n`Join Room`) + ]) + ]) + ]); } }