diff --git a/scripts/sdk/build.sh b/scripts/sdk/build.sh index 5e1632d3ee..e72d7dbfa8 100755 --- a/scripts/sdk/build.sh +++ b/scripts/sdk/build.sh @@ -12,6 +12,11 @@ yarn run vite build -c vite.sdk-assets-config.js yarn run vite build -c vite.sdk-lib-config.js yarn tsc -p tsconfig-declaration.json ./scripts/sdk/create-manifest.js ./target/package.json +pushd target/ +# Make sure the dependencies are available for any consuming project that uses +# `npm link hydrogen-view-sdk` +yarn install --no-lockfile +popd mkdir target/paths # this doesn't work, the ?url imports need to be in the consuming project, so disable for now # ./scripts/sdk/transform-paths.js ./src/platform/web/sdk/paths/vite.js ./target/paths/vite.js diff --git a/src/domain/ViewModel.ts b/src/domain/ViewModel.ts index 409e61c9c4..b8a4c98444 100644 --- a/src/domain/ViewModel.ts +++ b/src/domain/ViewModel.ts @@ -29,6 +29,7 @@ import type {ILogger} from "../logging/types"; import type {Navigation} from "./navigation/Navigation"; import type {SegmentType} from "./navigation/index"; import type {IURLRouter} from "./navigation/URLRouter"; +import type {History} from "../platform/web/dom/History"; import type { ITimeFormatter } from "../platform/types/types"; import type { FeatureSet } from "../features"; @@ -37,6 +38,7 @@ export type Options = { logger: ILogger; urlRouter: IURLRouter; navigation: Navigation; + history: History; emitChange?: (params: any) => void; features: FeatureSet } @@ -153,6 +155,10 @@ export class ViewModel = Op return this._options.navigation as unknown as Navigation; } + get history(): History { + return this._options.history; + } + get timeFormatter(): ITimeFormatter { return this._options.platform.timeFormatter; } diff --git a/src/domain/navigation/index.ts b/src/domain/navigation/index.ts index 4a573e157f..a84e129beb 100644 --- a/src/domain/navigation/index.ts +++ b/src/domain/navigation/index.ts @@ -34,6 +34,8 @@ export type SegmentType = { "details": true; "members": true; "member": string; + "change-dates": true; + "developer-options": true; "device-verification": string | boolean; "join-room": true; }; @@ -58,9 +60,9 @@ function allowsChild(parent: Segment | undefined, child: Segment, if (sessionSegment) { segments.push(sessionSegment); } - } else if (type === "details" || type === "members") { + } else if (type === "details" || type === "members" || type === "change-dates") { pushRightPanelSegment(segments, type); } else if (type === "member") { let userId = iterator.next().value; diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index dc8589d164..23b07515e7 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -20,7 +20,7 @@ import {RoomViewModel} from "./room/RoomViewModel.js"; import {UnknownRoomViewModel} from "./room/UnknownRoomViewModel.js"; import {InviteViewModel} from "./room/InviteViewModel.js"; import {RoomBeingCreatedViewModel} from "./room/RoomBeingCreatedViewModel.js"; -import {LightboxViewModel} from "./room/LightboxViewModel.js"; +import {setupLightboxNavigation} from "./room/lightbox-navigation.js"; import {SessionStatusViewModel} from "./SessionStatusViewModel.js"; import {RoomGridViewModel} from "./RoomGridViewModel.js"; import {SettingsViewModel} from "./settings/SettingsViewModel.js"; @@ -105,12 +105,12 @@ export class SessionViewModel extends ViewModel { this._updateVerification(verification.get()); } - const lightbox = this.navigation.observe("lightbox"); - this.track(lightbox.subscribe(eventId => { - this._updateLightbox(eventId); - })); - this._updateLightbox(lightbox.get()); - + setupLightboxNavigation(this, 'lightboxViewModel', (eventId) => { + return { + room: this._roomFromNavigation(), + eventId, + }; + }); const rightpanel = this.navigation.observe("right-panel"); this.track(rightpanel.subscribe(() => this._updateRightPanel())); diff --git a/src/domain/session/room/LightboxViewModel.js b/src/domain/session/room/LightboxViewModel.js index a14eef8978..ae18409e27 100644 --- a/src/domain/session/room/LightboxViewModel.js +++ b/src/domain/session/room/LightboxViewModel.js @@ -19,7 +19,8 @@ import {ViewModel} from "../../ViewModel"; export class LightboxViewModel extends ViewModel { constructor(options) { super(options); - this._eventId = options.eventId; + this._eventEntry = options.eventEntry; + this._eventId = options.eventId || options.eventEntry.id; this._unencryptedImageUrl = null; this._decryptedImage = null; this._closeUrl = this.urlRouter.urlUntilSegment("room"); @@ -28,11 +29,15 @@ export class LightboxViewModel extends ViewModel { } _subscribeToEvent(room, eventId) { - const eventObservable = room.observeEvent(eventId); - this.track(eventObservable.subscribe(eventEntry => { - this._loadEvent(room, eventEntry); - })); - this._loadEvent(room, eventObservable.get()); + let event = this._eventEntry; + if (!this._eventEntry) { + const eventObservable = room.observeEvent(eventId); + this.track(eventObservable.subscribe(eventEntry => { + this._loadEvent(room, eventEntry); + })); + event = eventObservable.get(); + } + this._loadEvent(room, event); } async _loadEvent(room, eventEntry) { @@ -91,6 +96,6 @@ export class LightboxViewModel extends ViewModel { } close() { - this.platform.history.pushUrl(this.closeUrl); + this.history.pushUrl(this.closeUrl); } } diff --git a/src/domain/session/room/lightbox-navigation.js b/src/domain/session/room/lightbox-navigation.js new file mode 100644 index 0000000000..1cc2886c40 --- /dev/null +++ b/src/domain/session/room/lightbox-navigation.js @@ -0,0 +1,69 @@ +/* +Copyright 2022 Bruno Windels +Copyright 2022 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 {LightboxViewModel} from "./LightboxViewModel.js"; + +// Store the `LightboxViewModel` under a symbol so no one else can tamper with +// it. This acts like a private field on the class since no one else has the +// symbol to look it up. +let lightboxViewModelSymbol = Symbol('lightboxViewModel'); + +/** + * Destroys and creates a new the `LightboxViewModel` depending if + * `lightboxChildOptions.eventEntry` or `lightboxChildOptions.eventId` are + * provided. + */ +function updateLightboxViewModel(vm, fieldName, lightboxChildOptions) { + // Remove any existing `LightboxViewModel` before we assemble the new one below + if (vm[lightboxViewModelSymbol]) { + vm[lightboxViewModelSymbol] = vm.disposeTracked(vm[lightboxViewModelSymbol]); + // Let the `LightboxView` know that the `LightboxViewModel` has changed + vm.emitChange(fieldName); + } + // Create the new `LightboxViewModel` if the `eventEntry` exists directly or + // `eventId` which we can load from the store + if (lightboxChildOptions.eventId || lightboxChildOptions.eventEntry) { + vm[lightboxViewModelSymbol] = vm.track(new LightboxViewModel(vm.childOptions(lightboxChildOptions))); + // Let the `LightboxView` know that the `LightboxViewModel` has changed + vm.emitChange(fieldName); + } +} + +/** + * Handles updating the `LightboxViewModel` whenever the page URL changes and + * emits changes which the `LightboxView` will use to re-render. This is a + * composable piece of logic to call in an existing `ViewModel`'s constructor. + */ +export function setupLightboxNavigation(vm, fieldName = 'lightboxViewModel', lightboxChildOptionsFunction) { + // On the given `vm`, create a getter at `fieldName` that the + // `LightboxViewModel` is exposed at for usage in the view. + Object.defineProperty(vm, fieldName, { + get: function() { + return vm[lightboxViewModelSymbol]; + } + }); + + // Whenever the page navigates somewhere, keep the `lightboxViewModel` up to date + const lightbox = vm.navigation.observe("lightbox"); + vm.track(lightbox.subscribe(eventId => { + updateLightboxViewModel(vm, fieldName, lightboxChildOptionsFunction(eventId)); + })); + // Also handle the case where the URL already includes `/lightbox/$eventId` (like + // from page-load) + const initialLightBoxEventId = lightbox.get(); + updateLightboxViewModel(vm, fieldName, lightboxChildOptionsFunction(initialLightBoxEventId)); +} diff --git a/src/domain/session/room/timeline/TimelineViewModel.js b/src/domain/session/room/timeline/TimelineViewModel.js index cf36fce4d9..60d080c6b9 100644 --- a/src/domain/session/room/timeline/TimelineViewModel.js +++ b/src/domain/session/room/timeline/TimelineViewModel.js @@ -47,9 +47,10 @@ export class TimelineViewModel extends ViewModel { this._requestedEndTile = null; this._requestScheduled = false; this._showJumpDown = false; + this._eventIdHighlighted = null; } - /** if this.tiles is empty, call this with undefined for both startTile and endTile */ + /** if this._tiles is empty, call this with undefined for both startTile and endTile */ setVisibleTileRange(startTile, endTile) { // don't clear these once done as they are used to check // for more tiles once loadAtTop finishes @@ -96,6 +97,23 @@ export class TimelineViewModel extends ViewModel { } } + setEventHighlight(eventId, newHighlightValue) { + const eventEntry = this._timeline.getByEventId(eventId); + if (eventEntry) { + eventEntry.setIsHighlighted(newHighlightValue); + + // If a new highlight, emit a change so we can scroll to this new highlight + if (newHighlightValue) { + this._eventIdHighlighted = eventId; + this.emitChange('eventIdHighlighted'); + } + } + } + + get eventIdHighlighted() { + return this._eventIdHighlighted; + } + get tiles() { return this._tiles; } diff --git a/src/domain/session/room/timeline/tiles/BaseMediaTile.js b/src/domain/session/room/timeline/tiles/BaseMediaTile.js index aa53661c53..af2feb44d7 100644 --- a/src/domain/session/room/timeline/tiles/BaseMediaTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMediaTile.js @@ -86,6 +86,14 @@ export class BaseMediaTile extends BaseMessageTile { } } + get mxcUrl() { + return this._getContent().url; + } + + get thumbnailMxcUrl() { + return this._getContent().info?.thumbnail_url; + } + get thumbnailUrl() { if (!this._isVisible) { return ""; @@ -93,7 +101,7 @@ export class BaseMediaTile extends BaseMessageTile { if (this._decryptedThumbnail) { return this._decryptedThumbnail.url; } else { - const thumbnailMxc = this._getContent().info?.thumbnail_url; + const thumbnailMxc = this.thumbnailMxcUrl; if (thumbnailMxc) { return this._mediaRepository.mxcUrlThumbnail(thumbnailMxc, this.width, this.height, "scale"); } diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js index 90da4bffa3..9808c2d5c3 100644 --- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js @@ -17,6 +17,8 @@ limitations under the License. import {SimpleTile} from "./SimpleTile.js"; import {ReactionsViewModel} from "../ReactionsViewModel.js"; import {getIdentifierColorNumber, avatarInitials, getAvatarHttpUrl} from "../../../../avatar"; +import {copyPlaintext} from "../../../../../platform/web/dom/utils"; + export class BaseMessageTile extends SimpleTile { @@ -57,6 +59,10 @@ export class BaseMessageTile extends SimpleTile { return `${this.urlRouter.urlUntilSegment("room")}/member/${this.sender}`; } + get eventId() { + return this._entry.id; + } + // Avatar view model contract get avatarColorNumber() { return getIdentifierColorNumber(this._entry.sender); @@ -79,7 +85,7 @@ export class BaseMessageTile extends SimpleTile { } get isOwn() { - return this._entry.sender === this._ownMember.userId; + return this._entry.sender === this._ownMember?.userId; } get isContinuation() { diff --git a/src/domain/session/room/timeline/tiles/SimpleTile.js b/src/domain/session/room/timeline/tiles/SimpleTile.js index 59ddf15b4c..366e685e0f 100644 --- a/src/domain/session/room/timeline/tiles/SimpleTile.js +++ b/src/domain/session/room/timeline/tiles/SimpleTile.js @@ -41,6 +41,10 @@ export class SimpleTile extends ErrorReportViewModel { return false; } + get isHighlighted() { + return this._entry.isHighlighted; + } + get needsDateSeparator() { return this._needsDateSeparator; } diff --git a/src/lib.ts b/src/lib.ts index 072d082db4..9f560b6a0a 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -38,6 +38,18 @@ export {SessionViewModel} from "./domain/session/SessionViewModel.js"; export {SessionView} from "./platform/web/ui/session/SessionView.js"; export {RoomViewModel} from "./domain/session/room/RoomViewModel.js"; export {RoomView} from "./platform/web/ui/session/room/RoomView.js"; +export {LightboxView} from "./platform/web/ui/session/room/LightboxView.js"; +export {setupLightboxNavigation} from "./domain/session/room/lightbox-navigation.js"; +export {RightPanelView} from "./platform/web/ui/session/rightpanel/RightPanelView.js"; +export {MediaRepository} from "./matrix/net/MediaRepository"; +export {HomeServerApi} from "./matrix/net/HomeServerApi"; +export {Storage} from "./matrix/storage/idb/Storage"; +export {StorageFactory} from "./matrix/storage/idb/StorageFactory"; +export {TilesCollection} from "./domain/session/room/timeline/TilesCollection.js"; +export {FragmentIdComparer} from "./matrix/room/timeline/FragmentIdComparer.js"; +export {EventEntry} from "./matrix/room/timeline/entries/EventEntry.js"; +export {encodeKey, decodeKey, encodeEventIdKey, decodeEventIdKey} from "./matrix/storage/idb/stores/TimelineEventStore"; +export {Timeline} from "./matrix/room/timeline/Timeline.js"; export {TimelineViewModel} from "./domain/session/room/timeline/TimelineViewModel.js"; export {tileClassForEntry} from "./domain/session/room/timeline/tiles/index"; export type {TimelineEntry, TileClassForEntryFn, Options, TileConstructor} from "./domain/session/room/timeline/tiles/index"; @@ -75,18 +87,24 @@ export {TextMessageView} from "./platform/web/ui/session/room/timeline/TextMessa export {VideoView} from "./platform/web/ui/session/room/timeline/VideoView.js"; export {Navigation} from "./domain/navigation/Navigation.js"; +export {URLRouter} from "./domain/navigation/URLRouter"; +export {History} from "./platform/web/dom/History.js"; export {ComposerViewModel} from "./domain/session/room/ComposerViewModel.js"; export {MessageComposer} from "./platform/web/ui/session/room/MessageComposer.js"; +export {text, tag} from "./platform/web/ui/general/html"; export {TemplateView} from "./platform/web/ui/general/TemplateView"; +export {ListView} from "./platform/web/ui/general/ListView"; export {ViewModel} from "./domain/ViewModel"; export {LoadingView} from "./platform/web/ui/general/LoadingView.js"; export {AvatarView} from "./platform/web/ui/AvatarView.js"; +export {avatarInitials, getIdentifierColorNumber} from "./domain/avatar" export {RoomType} from "./matrix/room/common"; export {EventEmitter} from "./utils/EventEmitter"; export {Disposables} from "./utils/Disposables"; export {LocalMedia} from "./matrix/calls/LocalMedia"; // these should eventually be moved to another library export { + ApplyMap, ObservableArray, SortedArray, MappedList, diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index a721092e43..a3cf433b75 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -85,6 +85,7 @@ export class Timeline { const readerRequest = this._disposables.track(this._timelineReader.readFromEnd(20, txn, log)); try { const entries = await readerRequest.complete(); + console.log('entries', entries) this._loadContextEntriesWhereNeeded(entries); this._setupEntries(entries); } finally { @@ -198,8 +199,10 @@ export class Timeline { if (!this._localEntries?.hasSubscriptions) { return; } - // find any local relations to this new remote event - for (const pee of this._localEntries) { + // find any local relations to these new remote events or maybe these + // new remote events reference one of the other new remote events we have. + const entryList = new ConcatList(entries, this._localEntries); + for (const pee of entryList) { // this will work because we set relatedEventId when removing remote echos if (pee.relatedEventId) { const relationTarget = entries.find(e => e.id === pee.relatedEventId); diff --git a/src/matrix/room/timeline/entries/EventEntry.js b/src/matrix/room/timeline/entries/EventEntry.js index cf56cbf954..674c704786 100644 --- a/src/matrix/room/timeline/entries/EventEntry.js +++ b/src/matrix/room/timeline/entries/EventEntry.js @@ -22,6 +22,7 @@ export class EventEntry extends BaseEventEntry { constructor(eventEntry, fragmentIdComparer) { super(fragmentIdComparer); this._eventEntry = eventEntry; + this._isHighlighted = false; this._decryptionError = null; this._decryptionResult = null; } @@ -77,6 +78,10 @@ export class EventEntry extends BaseEventEntry { return this._eventEntry.event.sender; } + get roomId() { + return this._eventEntry.event.room_id; + } + get displayName() { return this._eventEntry.displayName; } @@ -93,6 +98,14 @@ export class EventEntry extends BaseEventEntry { return this._eventEntry.event.event_id; } + get isHighlighted() { + return this._isHighlighted; + } + + setIsHighlighted(newValue) { + this._isHighlighted = newValue; + } + setDecryptionResult(result) { this._decryptionResult = result; } diff --git a/src/matrix/room/timeline/persistence/TimelineReader.js b/src/matrix/room/timeline/persistence/TimelineReader.js index 23ec4ed862..db274e282a 100644 --- a/src/matrix/room/timeline/persistence/TimelineReader.js +++ b/src/matrix/room/timeline/persistence/TimelineReader.js @@ -54,6 +54,7 @@ async function readRawTimelineEntriesWithTxn(roomId, eventKey, direction, amount } else { eventsWithinFragment = await timelineStore.eventsBefore(roomId, eventKey, amount); } + console.log('readRawTimelineEntriesWithTxn eventsWithinFragment', eventsWithinFragment) let eventEntries = eventsWithinFragment.map(e => new EventEntry(e, fragmentIdComparer)); entries = directionalConcat(entries, eventEntries, direction); // prepend or append eventsWithinFragment to entries, and wrap them in EventEntry diff --git a/src/matrix/storage/idb/StorageFactory.ts b/src/matrix/storage/idb/StorageFactory.ts index bc4470950a..91998a1a11 100644 --- a/src/matrix/storage/idb/StorageFactory.ts +++ b/src/matrix/storage/idb/StorageFactory.ts @@ -35,12 +35,11 @@ interface ServiceWorkerHandler { async function requestPersistedStorage(): Promise { // don't assume browser so we can run in node with fake-idb - const glob = this; - if (glob?.navigator?.storage?.persist) { - return await glob.navigator.storage.persist(); - } else if (glob?.document.requestStorageAccess) { + if (window?.navigator?.storage?.persist) { + return await window.navigator.storage.persist(); + } else if (window?.document.requestStorageAccess) { try { - await glob.document.requestStorageAccess(); + await window.document.requestStorageAccess(); return true; } catch (err) { console.warn("requestStorageAccess threw an error:", err); diff --git a/src/matrix/storage/idb/stores/TimelineEventStore.ts b/src/matrix/storage/idb/stores/TimelineEventStore.ts index 62a82fc092..4d95ecd176 100644 --- a/src/matrix/storage/idb/stores/TimelineEventStore.ts +++ b/src/matrix/storage/idb/stores/TimelineEventStore.ts @@ -40,20 +40,20 @@ interface TimelineEventEntry { type TimelineEventStorageEntry = TimelineEventEntry & { key: string, eventIdKey: string }; -function encodeKey(roomId: string, fragmentId: number, eventIndex: number): string { +export function encodeKey(roomId: string, fragmentId: number, eventIndex: number): string { return `${roomId}|${encodeUint32(fragmentId)}|${encodeUint32(eventIndex)}`; } -function decodeKey(key: string): { roomId: string, eventKey: EventKey } { +export function decodeKey(key: string): { roomId: string, eventKey: EventKey } { const [roomId, fragmentId, eventIndex] = key.split("|"); return {roomId, eventKey: new EventKey(decodeUint32(fragmentId), decodeUint32(eventIndex))}; } -function encodeEventIdKey(roomId: string, eventId: string): string { +export function encodeEventIdKey(roomId: string, eventId: string): string { return `${roomId}|${eventId}`; } -function decodeEventIdKey(eventIdKey: string): { roomId: string, eventId: string } { +export function decodeEventIdKey(eventIdKey: string): { roomId: string, eventId: string } { const [roomId, eventId] = eventIdKey.split("|"); return {roomId, eventId}; } diff --git a/src/matrix/storage/idb/utils.ts b/src/matrix/storage/idb/utils.ts index 44149e1275..948bdfafd6 100644 --- a/src/matrix/storage/idb/utils.ts +++ b/src/matrix/storage/idb/utils.ts @@ -80,6 +80,7 @@ export function openDatabase(name: string, createObjectStore: CreateObjectStore, try { await createObjectStore(db, txn, oldVersion, version); } catch (err) { + console.error(`openDatabase: Failed to createObjectStore in database=${name}`, err); // try aborting on error, if that hasn't been done already try { txn.abort(); diff --git a/src/platform/web/dom/History.js b/src/platform/web/dom/History.js index 81ddc7395d..7a232ee426 100644 --- a/src/platform/web/dom/History.js +++ b/src/platform/web/dom/History.js @@ -36,10 +36,10 @@ export class History extends BaseObservableValue { But for SSO, we need to handle /?loginToken= Handle that as a special case for now. */ - if (document.location.search.includes("loginToken")) { + if (document?.location?.search.includes("loginToken")) { return document.location.search; } - return document.location.hash; + return document?.location?.hash; } /** does not emit */ diff --git a/src/platform/web/dom/TimeFormatter.ts b/src/platform/web/dom/TimeFormatter.ts index c25e902bc8..162671ef7f 100644 --- a/src/platform/web/dom/TimeFormatter.ts +++ b/src/platform/web/dom/TimeFormatter.ts @@ -32,19 +32,21 @@ export class TimeFormatter implements ITimeFormatter { this.todayMidnight = new Date(); this.todayMidnight.setHours(0, 0, 0, 0); this.relativeDayFormatter = new Intl.RelativeTimeFormat(undefined, {numeric: "auto"}); - this.weekdayFormatter = new Intl.DateTimeFormat(undefined, {weekday: 'long'}); + this.weekdayFormatter = new Intl.DateTimeFormat(undefined, {weekday: 'long', timeZone: 'UTC'}); this.currentYearFormatter = new Intl.DateTimeFormat(undefined, { weekday: 'long', month: 'long', - day: 'numeric' + day: 'numeric', + timeZone: 'UTC' }); this.otherYearFormatter = new Intl.DateTimeFormat(undefined, { weekday: 'long', year: 'numeric', month: 'long', - day: 'numeric' + day: 'numeric', + timeZone: 'UTC' }); - this.timeFormatter = new Intl.DateTimeFormat(undefined, {hour: "numeric", minute: "2-digit"}); + this.timeFormatter = new Intl.DateTimeFormat(undefined, {hour: "numeric", minute: "2-digit", timeZone: 'UTC'}); } formatTime(date: Date): string { diff --git a/src/platform/web/ui/css/themes/element/timeline.css b/src/platform/web/ui/css/themes/element/timeline.css index 4a82260572..71fd0809f6 100644 --- a/src/platform/web/ui/css/themes/element/timeline.css +++ b/src/platform/web/ui/css/themes/element/timeline.css @@ -43,6 +43,7 @@ limitations under the License. /* TODO: check whether this is needed for .media to maintain aspect ratio (on IE11) like the 100% above */ /* width: 100%; */ box-sizing: border-box; + border-radius: 4px; } .Timeline_message:not(.continuation) { @@ -79,7 +80,6 @@ limitations under the License. .Timeline_message:hover:not(.disabled), .Timeline_message.selected, .Timeline_message.menuOpen { /* needs transparency support */ background-color: rgba(141, 151, 165, 0.1); - border-radius: 4px; } .Timeline_message:hover > .Timeline_messageOptions, @@ -88,6 +88,24 @@ limitations under the License. user-select: none; } +.Timeline_message.highlighted, +.AnnouncementView.highlighted { + background-color: #ffff8a; +} + +.Timeline_message.highlighted:hover:not(.disabled), +.AnnouncementView.highlighted:hover, +.Timeline_message.highlighted.selected:hover +.Timeline_message.highlighted.menuOpen:hover { + background-color: #e0f4ff; +} + +/* Prevent messages nested in a reply from being highlighted */ +.ReplyPreviewView .Timeline_message.highlighted, +.ReplyPreviewView .AnnouncementView.highlighted { + background-color: transparent; +} + .Timeline_messageAvatar { grid-area: avatar; text-decoration: none; @@ -433,7 +451,7 @@ only loads when the top comes into view*/ .DateHeader time { margin: 0 auto; padding: 12px 4px; - width: 250px; + max-width: 350px; padding: 12px; display: block; color: var(--light-text-color); diff --git a/src/platform/web/ui/css/timeline.css b/src/platform/web/ui/css/timeline.css index 0932739ced..baf99428ad 100644 --- a/src/platform/web/ui/css/timeline.css +++ b/src/platform/web/ui/css/timeline.css @@ -40,6 +40,10 @@ limitations under the License. .Timeline_scroller > ul { list-style: none; + /* If there are only a few events, align them to the bottom */ + display: flex; + flex-direction: column; + justify-content: flex-end; /* use small horizontal padding so first/last children margin isn't collapsed at the edge and a scrollbar shows up when setting margin-top to bottom-align content when there are not yet enough tiles to fill the viewport */ diff --git a/src/platform/web/ui/session/rightpanel/RightPanelView.js b/src/platform/web/ui/session/rightpanel/RightPanelView.js index 5297d2ef73..1588768866 100644 --- a/src/platform/web/ui/session/rightpanel/RightPanelView.js +++ b/src/platform/web/ui/session/rightpanel/RightPanelView.js @@ -39,6 +39,8 @@ export class RightPanelView extends TemplateView { return new MemberListView(vm); case "member-details": return new MemberDetailsView(vm); + case "custom": + return new vm.customView(vm); default: return new LoadingView(); } diff --git a/src/platform/web/ui/session/room/DisabledComposerView.js b/src/platform/web/ui/session/room/DisabledComposerView.js index caa8eeb9a8..929786df5f 100644 --- a/src/platform/web/ui/session/room/DisabledComposerView.js +++ b/src/platform/web/ui/session/room/DisabledComposerView.js @@ -17,7 +17,7 @@ limitations under the License. import {TemplateView} from "../../general/TemplateView"; export class DisabledComposerView extends TemplateView { - render(t) { - return t.div({className: "DisabledComposerView"}, t.h3(vm => vm.description)); + render(t, vm) { + return t.div({className: "DisabledComposerView"}, t.h3(vm.description)); } } diff --git a/src/platform/web/ui/session/room/RoomView.js b/src/platform/web/ui/session/room/RoomView.js index 727fb44ddd..3d6573b4be 100644 --- a/src/platform/web/ui/session/room/RoomView.js +++ b/src/platform/web/ui/session/room/RoomView.js @@ -26,27 +26,42 @@ import {AvatarView} from "../../AvatarView.js"; import {CallView} from "./CallView"; import { ErrorView } from "../../general/ErrorView"; +class RoomHeaderView extends TemplateView { + render(t, vm) { + return t.div({className: "RoomHeader middle-header"}, [ + t.a({className: "button-utility close-middle", href: vm.closeUrl, title: vm.i18n`Close room`}), + t.view(new AvatarView(vm, 32)), + t.div({className: "room-description"}, [ + t.h2(vm => vm.name), + ]), + t.button({ + className: "button-utility room-options", + "aria-label":vm.i18n`Room options`, + onClick: evt => this._toggleOptionsMenu(evt) + }) + ]) + } +} + export class RoomView extends TemplateView { - constructor(vm, viewClassForTile) { + constructor(vm, viewClassForTile, slots) { super(vm); this._viewClassForTile = viewClassForTile; this._optionsPopup = null; + this._slots = slots || {}; } render(t, vm) { + let headerView; + if (this._slots.RoomHeaderView) { + headerView = new this._slots.RoomHeaderView(vm) + } else { + headerView = new RoomHeaderView(vm) + } + + return t.main({className: "RoomView middle"}, [ - t.div({className: "RoomHeader middle-header"}, [ - t.a({className: "button-utility close-middle", href: vm.closeUrl, title: vm.i18n`Close room`}), - t.view(new AvatarView(vm, 32)), - t.div({className: "room-description"}, [ - t.h2(vm => vm.name), - ]), - t.button({ - className: "button-utility room-options", - "aria-label":vm.i18n`Room options`, - onClick: evt => this._toggleOptionsMenu(evt) - }) - ]), + t.view(headerView), t.div({className: "RoomView_body"}, [ t.if(vm => vm.errorViewModel, t => t.div({className: "RoomView_error"}, t.view(new ErrorView(vm.errorViewModel)))), t.mapView(vm => vm.callViewModel, callViewModel => callViewModel ? new CallView(callViewModel) : null), diff --git a/src/platform/web/ui/session/room/TimelineView.ts b/src/platform/web/ui/session/room/TimelineView.ts index 5a04991fba..2c1d79f764 100644 --- a/src/platform/web/ui/session/room/TimelineView.ts +++ b/src/platform/web/ui/session/room/TimelineView.ts @@ -39,6 +39,7 @@ export type ViewClassForEntryFn = (tile: SimpleTile) => TileViewConstructor; export interface TimelineViewModel extends IObservableValue { showJumpDown: boolean; tiles: ObservableList; + eventIdHighlighted: string; setVisibleTileRange(start?: SimpleTile, end?: SimpleTile); } @@ -91,6 +92,17 @@ export class TimelineView extends TemplateView { }) ]); + t.mapSideEffect(vm => vm.eventIdHighlighted, (eventIdHighlighted) => { + if (eventIdHighlighted) { + // assume this view will be mounted in the parent DOM straight away + requestAnimationFrame(() => { + this.scrollToEventId(eventIdHighlighted, { + block: 'center' + }); + }); + } + }); + if (typeof ResizeObserver === "function") { this.resizeObserver = new ResizeObserver(() => { this.restoreScrollPosition(); @@ -176,6 +188,20 @@ export class TimelineView extends TemplateView { this.updateVisibleRange(topNodeIndex, bottomNodeIndex); } + private scrollToEventId(eventId: string, scrollIntoViewOptions?: object) { + const {tilesNode} = this; + const eventEl = [...tilesNode.childNodes].find((node: HTMLElement) => { + return node.matches(`[data-event-id="${eventId}"]`); + }) as HTMLElement; + + if (eventEl) { + eventEl.scrollIntoView(scrollIntoViewOptions); + this.stickToBottom = false; + } + + return !!eventEl; + } + private updateVisibleRange(startIndex: number, endIndex: number) { // can be undefined, meaning the tiles collection is still empty const firstVisibleChild = this.tilesView!.getChildInstanceByIndex(startIndex); diff --git a/src/platform/web/ui/session/room/timeline/AnnouncementView.js b/src/platform/web/ui/session/room/timeline/AnnouncementView.js index 8b68d33bd5..36144f6de6 100644 --- a/src/platform/web/ui/session/room/timeline/AnnouncementView.js +++ b/src/platform/web/ui/session/room/timeline/AnnouncementView.js @@ -24,7 +24,10 @@ export class AnnouncementView extends TemplateView { render(t, vm) { return t.li({ - className: "AnnouncementView", + className: { + "AnnouncementView": true, + "highlighted": vm => vm.isHighlighted, + }, 'data-event-id': vm.eventId }, t.div(vm => vm.announcement)); } diff --git a/src/platform/web/ui/session/room/timeline/BaseMessageView.js b/src/platform/web/ui/session/room/timeline/BaseMessageView.js index 84a4a1ad0d..f0e0f94d1e 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMessageView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js @@ -49,6 +49,7 @@ export class BaseMessageView extends TemplateView { unverified: vm => vm.isUnverified, disabled: !this._interactive, continuation: vm => vm.isContinuation, + "highlighted": vm => vm.isHighlighted, }, 'data-event-id': vm.eventId }, children); @@ -126,6 +127,7 @@ export class BaseMessageView extends TemplateView { } else if (vm.canRedact) { options.push(Menu.option(vm.i18n`Delete`, () => vm.redact()).setDestructive()); } + options.push(Menu.option(vm.i18n`Copy matrix.to permalink`, () => vm.copyPermalink())); return options; } diff --git a/src/platform/web/ui/session/room/timeline/ImageView.js b/src/platform/web/ui/session/room/timeline/ImageView.js index 19591606e6..5b850eebf8 100644 --- a/src/platform/web/ui/session/room/timeline/ImageView.js +++ b/src/platform/web/ui/session/room/timeline/ImageView.js @@ -22,6 +22,8 @@ export class ImageView extends BaseMediaView { src: vm => vm.thumbnailUrl, alt: vm => vm.label, title: vm => vm.label, + 'data-mxc-url': vm => vm.mxcUrl, + 'data-thumbnail-mxc-url': vm => vm.thumbnailMxcUrl, style: `max-width: ${vm.width}px; max-height: ${vm.height}px;` }); return vm.isPending || !vm.lightboxUrl ? img : t.a({href: vm.lightboxUrl}, img); diff --git a/src/platform/web/ui/session/room/timeline/VideoView.js b/src/platform/web/ui/session/room/timeline/VideoView.js index 9b092ed091..b5d19ffe35 100644 --- a/src/platform/web/ui/session/room/timeline/VideoView.js +++ b/src/platform/web/ui/session/room/timeline/VideoView.js @@ -24,6 +24,7 @@ export class VideoView extends BaseMediaView { // Chrome/Electron need this to enable the play button. src: vm => vm.videoUrl || `data:${vm.mimeType},`, title: vm => vm.label, + 'data-mxc-url': vm => vm.mxcUrl, controls: true, preload: "none", poster: vm => vm.thumbnailUrl,