Skip to content
Closed
6 changes: 4 additions & 2 deletions src/domain/session/SessionViewModel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
53 changes: 52 additions & 1 deletion src/domain/session/room/UnknownRoomViewModel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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() {
Expand Down Expand Up @@ -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();
Copy link

@bwindels bwindels Feb 13, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting ... how does this work if you don't already have a fragment of the timeline stored? If I understand things correctly, at this point, the room exists solely in-memory and nothing related has been written to storage, right? So what token do you end up calling /messages with? I might very well be missing something :)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually loadWorldReadableRoom() above it does all of that - creates the Room object instance, fetch events from messages endpoint & load it all. You will see that here - https://github.com/Automattic/hydrogen-web/pull/3/files#diff-25d3af8c52953975b54837c62ef3195e1054638339aa9b1a9c1d6bc267cdadc8R962

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");
}
}
}
127 changes: 127 additions & 0 deletions src/matrix/Session.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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<resp.length; i++ ) {
if ( resp[i].type === 'm.room.name') {
summary["name"] = resp[i].content.name;
} else if ( resp[i].type === 'm.room.canonical_alias' ) {
summary["canonicalAlias"] = resp[i].content.alias;
} else if ( resp[i].type === 'm.room.avatar' ) {
summary["avatarUrl"] = resp[i].content.url;
}
}

return summary;
});
}

async _fetchWorldReadableRoomEvents(roomId, limit = 30, dir = 'b', end = null, log = null) {
return this._platform.logger.wrapOrRun(log, "fetchWorldReadableRoomEvents", async log => {
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";
Expand Down
8 changes: 8 additions & 0 deletions src/matrix/net/HomeServerApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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});
}
Expand Down Expand Up @@ -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"));
}
Expand Down
28 changes: 27 additions & 1 deletion src/platform/web/ui/css/themes/element/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -975,7 +975,7 @@ button.link {
width: 100%;
}

.DisabledComposerView {
.DisabledComposerView, .WorldReadableRoomComposerView {
padding: 12px;
background-color: var(--background-color-secondary);
}
Expand All @@ -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%;
Expand Down
82 changes: 69 additions & 13 deletions src/platform/web/ui/session/room/UnknownRoomView.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
])
])
]);
}
}