├── icon.png ├── prototypes ├── me.jpg ├── manifest.appcache ├── base256.html ├── online.html ├── chrome-keys.html ├── idb-multi-key.html ├── idb2rwtxn.html ├── idb-cmp.html ├── chrome-keys2.html └── chrome-keys3.html ├── doc ├── domexception_mapping.md ├── impl-thoughts │ ├── html-messages.md │ ├── LOGIN.md │ ├── DESIGN.md │ ├── CATCHUP-BACKFILL.md │ ├── session-container.md │ ├── background-tasks.md │ ├── LOCAL-ECHO-STATE.md │ ├── ROOM-VERSIONS.md │ ├── VIEW-UPDATES.md │ └── RELATIONS.md ├── invites.md ├── persisted-network-calls.md ├── GOAL.md ├── RELEASE.md ├── viewhierarchy.md ├── sync-updates.md ├── QUESTIONS.md ├── api.md └── CSS.md ├── .gitignore ├── .eslintrc.js ├── .editorconfig ├── scripts ├── release.sh └── serve-local.js ├── src ├── Platform.js ├── matrix │ ├── room │ │ ├── timeline │ │ │ ├── common.js │ │ │ ├── persistence │ │ │ │ └── common.js │ │ │ ├── Direction.js │ │ │ ├── entries │ │ │ │ ├── PendingEventEntry.js │ │ │ │ ├── EventEntry.js │ │ │ │ └── BaseEntry.js │ │ │ └── Timeline.js │ │ └── sending │ │ │ └── PendingEvent.js │ ├── User.js │ ├── storage │ │ ├── idb │ │ │ ├── stores │ │ │ │ ├── MemberStore.js │ │ │ │ ├── RoomStateStore.js │ │ │ │ ├── RoomSummaryStore.js │ │ │ │ ├── SessionStore.js │ │ │ │ ├── PendingEventStore.js │ │ │ │ └── TimelineFragmentStore.js │ │ │ ├── export.js │ │ │ ├── Storage.js │ │ │ ├── StorageFactory.js │ │ │ └── Transaction.js │ │ ├── memory │ │ │ ├── stores │ │ │ │ └── Store.js │ │ │ ├── Storage.js │ │ │ └── Transaction.js │ │ └── common.js │ ├── error.js │ ├── sessioninfo │ │ └── localstorage │ │ │ └── SessionInfoStorage.js │ └── net │ │ └── request │ │ └── fetch.js ├── utils │ ├── error.js │ ├── enum.js │ ├── sortedIndex.js │ ├── Disposables.js │ └── EventEmitter.js ├── domain │ ├── session │ │ ├── avatar.js │ │ ├── room │ │ │ └── timeline │ │ │ │ ├── tiles │ │ │ │ ├── RoomNameTile.js │ │ │ │ ├── TextTile.js │ │ │ │ ├── LocationTile.js │ │ │ │ ├── MessageTile.js │ │ │ │ ├── ImageTile.js │ │ │ │ ├── GapTile.js │ │ │ │ ├── RoomMemberTile.js │ │ │ │ └── SimpleTile.js │ │ │ │ ├── UpdateAction.js │ │ │ │ ├── tilesCreator.js │ │ │ │ └── TimelineViewModel.js │ │ ├── roomlist │ │ │ └── RoomTileViewModel.js │ │ └── SessionViewModel.js │ ├── ViewModel.js │ └── LoginViewModel.js ├── ui │ └── web │ │ ├── css │ │ ├── login.css │ │ ├── avatar.css │ │ ├── spinner.css │ │ ├── left-panel.css │ │ ├── layout.css │ │ ├── room.css │ │ ├── timeline.css │ │ └── main.css │ │ ├── common.js │ │ ├── session │ │ ├── room │ │ │ ├── timeline │ │ │ │ ├── AnnouncementView.js │ │ │ │ ├── TextMessageView.js │ │ │ │ ├── GapView.js │ │ │ │ ├── TimelineTile.js │ │ │ │ └── ImageView.js │ │ │ ├── MessageComposer.js │ │ │ ├── RoomView.js │ │ │ └── TimelineList.js │ │ ├── RoomPlaceholderView.js │ │ ├── RoomTile.js │ │ ├── SessionStatusView.js │ │ └── SessionView.js │ │ ├── general │ │ ├── error.js │ │ └── SwitchView.js │ │ ├── login │ │ ├── SessionLoadView.js │ │ ├── common.js │ │ └── LoginView.js │ │ ├── WebPlatform.js │ │ ├── dom │ │ ├── OnlineStatus.js │ │ └── Clock.js │ │ ├── BrawlView.js │ │ └── view-gallery.html ├── observable │ ├── map │ │ ├── BaseObservableMap.js │ │ ├── FilteredMap.js │ │ └── MappedMap.js │ ├── index.js │ ├── list │ │ ├── ObservableArray.js │ │ ├── BaseObservableList.js │ │ └── SortedArray.js │ └── BaseObservable.js ├── service-worker.template.js └── main.js ├── package.json ├── README.md └── index.html /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bwindels/brawl-chat/HEAD/icon.png -------------------------------------------------------------------------------- /prototypes/me.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bwindels/brawl-chat/HEAD/prototypes/me.jpg -------------------------------------------------------------------------------- /prototypes/manifest.appcache: -------------------------------------------------------------------------------- 1 | CACHE MANIFEST 2 | # v1 3 | /responsive-layout-flex.html 4 | /me.jpg 5 | -------------------------------------------------------------------------------- /doc/domexception_mapping.md: -------------------------------------------------------------------------------- 1 | err.name: explanation 2 | DataError: parameters to idb request where invalid 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.sublime-project 2 | *.sublime-workspace 3 | node_modules 4 | fetchlogs 5 | sessionexports 6 | bundle.js 7 | target 8 | -------------------------------------------------------------------------------- /doc/impl-thoughts/html-messages.md: -------------------------------------------------------------------------------- 1 | message model: 2 | - paragraphs (p, h1, code block, quote, ...) 3 | - lines 4 | - parts (inline markup), which can be recursive 5 | -------------------------------------------------------------------------------- /doc/invites.md: -------------------------------------------------------------------------------- 1 | # Invites 2 | 3 | - invite_state doesn't update over /sync 4 | - can we reuse room summary? need to clear when joining 5 | - rely on filter operator to split membership=join from membership=invite? 6 | - 7 | -------------------------------------------------------------------------------- /doc/impl-thoughts/LOGIN.md: -------------------------------------------------------------------------------- 1 | LoginView 2 | LoginViewModel 3 | SessionPickerView 4 | SessionPickerViewModel 5 | 6 | matrix: 7 | SessionStorage (could be in keychain, ... for now we go with localstorage) 8 | getAll() 9 | 10 | Login 11 | -------------------------------------------------------------------------------- /doc/impl-thoughts/DESIGN.md: -------------------------------------------------------------------------------- 1 | use mock view models or even a mock session to render different states of the app in a static html document, where we can somehow easily tweak the css (just browser tools, or do something in the page?) how to persist css after changes? 2 | 3 | Also dialogs, forms, ... could be shown on this page. 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "browser": true, 4 | "es6": true 5 | }, 6 | "extends": "eslint:recommended", 7 | "parserOptions": { 8 | "ecmaVersion": 2018, 9 | "sourceType": "module" 10 | }, 11 | "rules": { 12 | "no-console": "off" 13 | } 14 | }; -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | 2 | # top-most EditorConfig file 3 | root = true 4 | 5 | # Unix-style newlines with a newline ending every file 6 | [*] 7 | end_of_line = lf 8 | insert_final_newline = true 9 | charset = utf-8 10 | indent_style = space 11 | indent_size = 4 12 | 13 | # Matches multiple files with brace expansion notation 14 | # Set default charset 15 | # [*.{js,py}] 16 | -------------------------------------------------------------------------------- /doc/persisted-network-calls.md: -------------------------------------------------------------------------------- 1 | # General Pattern of implementing a persisted network call 2 | 3 | 1. do network request 4 | 1. start transaction 5 | 1. write result of network request into transaction store, keeping differences from previous store state in local variables 6 | 1. close transaction 7 | 1. apply differences applied to store to in-memory data 8 | 1. emit events for changes 9 | -------------------------------------------------------------------------------- /doc/GOAL.md: -------------------------------------------------------------------------------- 1 | goal: 2 | 3 | write client that works on lumia 950 phone, so I can use matrix on my phone. 4 | 5 | try approach offline to indexeddb. go low-memory, and test the performance of storing every event individually in indexeddb. 6 | 7 | try to use little bandwidth, mainly by being an offline application and storing all requested data in indexeddb. 8 | be as functional as possible while offline 9 | -------------------------------------------------------------------------------- /doc/impl-thoughts/CATCHUP-BACKFILL.md: -------------------------------------------------------------------------------- 1 | we should automatically fill gaps (capped at a certain (large) amount of events, 5000?) after a limited sync for a room 2 | 3 | ## E2EE rooms 4 | 5 | during these fills (once supported), we should calculate push actions and trigger notifications, as we would otherwise have received this through sync. 6 | 7 | we could also trigger notifications when just backfilling on initial sync up to a certain amount of time in the past? 8 | -------------------------------------------------------------------------------- /scripts/release.sh: -------------------------------------------------------------------------------- 1 | if [ -z "$1" ]; then 2 | echo "provide a new version, current version is $(jq '.version' package.json)" 3 | exit 1 4 | fi 5 | VERSION=$1 6 | git checkout master 7 | git pull --rebase origin master 8 | jq ".version = \"$VERSION\"" package.json > package.json.tmp 9 | rm package.json 10 | mv package.json.tmp package.json 11 | git add package.json 12 | git commit -m "release v$VERSION" 13 | git tag "v$VERSION" 14 | git push --tags origin master 15 | -------------------------------------------------------------------------------- /doc/RELEASE.md: -------------------------------------------------------------------------------- 1 | release: 2 | - bundling css files 3 | - bundling javascript 4 | - run index.html template for release as opposed to develop version? 5 | - make list of all resources needed (images, html page) 6 | - create appcache manifest + service worker 7 | - create tarball + sign 8 | - make gh release with tarball + signature 9 | publish: 10 | - extract tarball 11 | - upload to static website 12 | - overwrite index.html 13 | - overwrite service worker & appcache manifest 14 | - put new version files under /x.x.x 15 | -------------------------------------------------------------------------------- /doc/impl-thoughts/session-container.md: -------------------------------------------------------------------------------- 1 | what should this new container be called? 2 | - Client 3 | - SessionContainer 4 | 5 | 6 | it is what is returned from bootstrapping a ... thing 7 | it allows you to replace classes within the client through IoC? 8 | it wires up the different components 9 | it unwires the components when you're done with the thing 10 | it could hold all the dependencies for setting up a client, even before login 11 | - online detection api 12 | - clock 13 | - homeserver 14 | - requestFn 15 | 16 | we'll be explicitly making its parts public though, like session, sync, reconnector 17 | 18 | merge the connectionstate and 19 | -------------------------------------------------------------------------------- /doc/viewhierarchy.md: -------------------------------------------------------------------------------- 1 | view hierarchy: 2 | ``` 3 | BrawlView 4 | SwitchView 5 | SessionView 6 | SyncStatusBar 7 | ListView(left-panel) 8 | RoomTile 9 | SwitchView 10 | RoomPlaceholderView 11 | RoomView 12 | MiddlePanel 13 | ListView(timeline) 14 | event tiles (see ui/session/room/timeline/) 15 | ComposerView 16 | RightPanel 17 | SessionPickView 18 | ListView 19 | SessionPickerItemView 20 | LoginView 21 | ``` 22 | -------------------------------------------------------------------------------- /src/Platform.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | export {WebPlatform as Platform} from "./ui/web/WebPlatform.js"; 18 | -------------------------------------------------------------------------------- /src/matrix/room/timeline/common.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | export function isValidFragmentId(id) { 18 | return typeof id === "number"; 19 | } 20 | -------------------------------------------------------------------------------- /doc/impl-thoughts/background-tasks.md: -------------------------------------------------------------------------------- 1 | we make the current session status bar float and display generally short messages for all background tasks like: 2 | "Waiting Xs to reconnect... [try now]" 3 | "Reconnecting..." 4 | "Sending message 1 of 10..." 5 | 6 | As it is floating, it doesn't pop they layout and mess up the scroll offset of the timeline. 7 | Need to find a good place to float it though. Preferably on top for visibility, but it could occlude the room header. Perhaps bottom left? 8 | 9 | If more than 1 background thing is going on at the same time we display (1/x). 10 | If you click the button status bar anywhere, it takes you to a page adjacent to the room view (and e.g. in the future the settings) and you get an overview of all running background tasks. 11 | -------------------------------------------------------------------------------- /src/utils/error.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | export class AbortError extends Error { 18 | get name() { 19 | return "AbortError"; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/matrix/User.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | export class User { 18 | constructor(userId) { 19 | this._userId = userId; 20 | } 21 | 22 | get id() { 23 | return this._userId; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/domain/session/avatar.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | export function avatarInitials(name) { 18 | const words = name.split(" ").slice(0, 2); 19 | return words.reduce((i, w) => i + w.charAt(0).toUpperCase(), ""); 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/enum.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | export function createEnum(...values) { 18 | const obj = {}; 19 | for (const value of values) { 20 | obj[value] = value; 21 | } 22 | return Object.freeze(obj); 23 | } 24 | -------------------------------------------------------------------------------- /src/ui/web/css/login.css: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | .SessionLoadView { 18 | display: flex; 19 | } 20 | 21 | .SessionLoadView p { 22 | flex: 1; 23 | margin: 0 0 0 10px; 24 | } 25 | 26 | .SessionLoadView .spinner { 27 | --size: 20px; 28 | } 29 | -------------------------------------------------------------------------------- /prototypes/base256.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/ui/web/common.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | export function spinner(t, extraClasses = undefined) { 18 | return t.svg({className: Object.assign({"spinner": true}, extraClasses), viewBox:"0 0 100 100"}, 19 | t.circle({cx:"50%", cy:"50%", r:"45%", pathLength:"100"}) 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /prototypes/online.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
    8 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/ui/web/session/room/timeline/AnnouncementView.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {TemplateView} from "../../../general/TemplateView.js"; 18 | 19 | export class AnnouncementView extends TemplateView { 20 | render(t) { 21 | return t.li({className: "AnnouncementView"}, t.div(vm => vm.announcement)); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /doc/impl-thoughts/LOCAL-ECHO-STATE.md: -------------------------------------------------------------------------------- 1 | # Local echo 2 | 3 | ## Remote vs local state for account_data, etc ... 4 | 5 | For things like account data, and other requests that might fail, we could persist what we are sending next to the last remote version we have (with a flag for which one is remote and local, part of the key). E.g. for account data the key would be: [type, localOrRemoteFlag] 6 | 7 | localOrRemoteFlag would be 1 of 3: 8 | - Remote 9 | - (Local)Unsent 10 | - (Local)Sent 11 | 12 | although we only want 1 remote and 1 local value for a given key, perhaps a second field where localOrRemoteFlag is a boolean, and a sent=boolean field as well? We need this to know if we need to retry. 13 | 14 | This will allow resending of these requests if needed. Once the request goes through, we remove the local version. 15 | 16 | then we can also see what the current value is with or without the pending local changes, and we don't have to wait for remote echo... 17 | -------------------------------------------------------------------------------- /doc/impl-thoughts/ROOM-VERSIONS.md: -------------------------------------------------------------------------------- 1 | - add internal room ids (to support room versioning later, and make internal event ids smaller and not needing escaping, and not needing a migration later on) ... hm this might need some more though. how to address a logical room? last room id? also we might not need it for room versioning ... it would basically be to make the ids smaller, but as idb is compressing, not sure that's a good reason? Although as we keep all room summaries in memory, it would be easy to map between these... you'd get event ids like 0000E78A00000020000A0B3C with room id, fragment id and event index. The room summary would store: 2 | ``` 3 | rooms: { 4 | "!eKhOsgLidcrWMWnxOr:vector.modular.im": 0x0000E78A, 5 | ... 6 | } 7 | mostRecentRoom: 0x0000E78A 8 | ``` 9 | if this is not on an indexed field, how can we do a query to find the last room id and +1 to assign a new one? 10 | 11 | how do we identify a logical room (consisting on a recent room and perhaps multiple outdated ones)? 12 | -------------------------------------------------------------------------------- /doc/sync-updates.md: -------------------------------------------------------------------------------- 1 | # persistance vs model update of a room 2 | 3 | ## persist first, return update object, update model with update object 4 | - we went with this 5 | ## update model first, return update object, persist with update object 6 | - not all models exist at all times (timeline only when room is "open"), 7 | so model to create timeline update object might not exist for persistence need 8 | 9 | ## persist, update, each only based on sync data (independent of each other) 10 | - possible inconsistency between syncing and loading from storage as they are different code paths 11 | + storage code remains very simple and focussed 12 | 13 | ## updating model and persisting in one go 14 | - if updating model needs to do anything async, it needs to postpone it or the txn will be closed 15 | 16 | ## persist first, read from storage to update model 17 | + guaranteed consistency between what is on screen and in storage 18 | - slower as we need to reread what was just synced every time (big accounts with frequent updates) 19 | -------------------------------------------------------------------------------- /src/domain/session/room/timeline/tiles/RoomNameTile.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {SimpleTile} from "./SimpleTile.js"; 18 | 19 | export class RoomNameTile extends SimpleTile { 20 | 21 | get shape() { 22 | return "announcement"; 23 | } 24 | 25 | get announcement() { 26 | const content = this._entry.content; 27 | return `${this._entry.sender} named the room "${content.name}"` 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/ui/web/general/error.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {tag} from "./html.js"; 18 | 19 | export function errorToDOM(error) { 20 | const stack = new Error().stack; 21 | const callee = stack.split("\n")[1]; 22 | return tag.div([ 23 | tag.h2("Something went wrong…"), 24 | tag.h3(error.message), 25 | tag.p(`This occurred while running ${callee}.`), 26 | tag.pre(error.stack), 27 | ]); 28 | } 29 | -------------------------------------------------------------------------------- /src/ui/web/login/SessionLoadView.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {TemplateView} from "../general/TemplateView.js"; 18 | import {spinner} from "../common.js"; 19 | 20 | export class SessionLoadView extends TemplateView { 21 | render(t) { 22 | return t.div({className: "SessionLoadView"}, [ 23 | spinner(t, {hiddenWithLayout: vm => !vm.loading}), 24 | t.p(vm => vm.loadLabel) 25 | ]); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/ui/web/login/common.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | export function brawlGithubLink(t) { 18 | if (window.BRAWL_VERSION) { 19 | return t.a({target: "_blank", href: `https://github.com/bwindels/brawl-chat/releases/tag/v${window.BRAWL_VERSION}`}, `Brawl v${window.BRAWL_VERSION} on Github`); 20 | } else { 21 | return t.a({target: "_blank", href: "https://github.com/bwindels/brawl-chat"}, "Brawl on Github"); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/matrix/storage/idb/stores/MemberStore.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // no historical members for now 18 | class MemberStore { 19 | async getMember(roomId, userId) { 20 | 21 | } 22 | 23 | /* async getMemberAtSortKey(roomId, userId, sortKey) { 24 | 25 | } */ 26 | // multiple members here? does it happen at same sort key? 27 | async setMembers(roomId, members) { 28 | 29 | } 30 | 31 | async getSortedMembers(roomId, offset, amount) { 32 | 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/domain/session/room/timeline/tiles/TextTile.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {MessageTile} from "./MessageTile.js"; 18 | 19 | export class TextTile extends MessageTile { 20 | get text() { 21 | const content = this._getContent(); 22 | const body = content && content.body; 23 | if (content.msgtype === "m.emote") { 24 | return `* ${this._entry.sender} ${body}`; 25 | } else { 26 | return body; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "brawl-chat", 3 | "version": "0.0.27", 4 | "description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB", 5 | "main": "index.js", 6 | "directories": { 7 | "doc": "doc" 8 | }, 9 | "scripts": { 10 | "test": "node_modules/.bin/impunity --entry-point src/main.js --force-esm", 11 | "start": "node scripts/serve-local.js", 12 | "build": "node --experimental-modules scripts/build.mjs" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/bwindels/brawl-chat.git" 17 | }, 18 | "author": "Bruno Windels", 19 | "license": "Apache-2.0", 20 | "bugs": { 21 | "url": "https://github.com/bwindels/brawl-chat/issues" 22 | }, 23 | "homepage": "https://github.com/bwindels/brawl-chat#readme", 24 | "devDependencies": { 25 | "cheerio": "^1.0.0-rc.3", 26 | "finalhandler": "^1.1.1", 27 | "impunity": "^0.0.11", 28 | "postcss": "^7.0.18", 29 | "postcss-import": "^12.0.1", 30 | "rollup": "^1.15.6", 31 | "serve-static": "^1.13.2" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/matrix/storage/idb/stores/RoomStateStore.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | export class RoomStateStore { 18 | constructor(idbStore) { 19 | this._roomStateStore = idbStore; 20 | } 21 | 22 | async getEvents(type) { 23 | 24 | } 25 | 26 | async getEventsForKey(type, stateKey) { 27 | 28 | } 29 | 30 | async setStateEvent(roomId, event) { 31 | const key = `${roomId}|${event.type}|${event.state_key}`; 32 | const entry = {roomId, event, key}; 33 | return this._roomStateStore.put(entry); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/ui/web/session/RoomPlaceholderView.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {tag} from "../general/html.js"; 18 | 19 | export class RoomPlaceholderView { 20 | constructor() { 21 | this._root = null; 22 | } 23 | 24 | mount() { 25 | this._root = tag.div({className: "RoomPlaceholderView"}, tag.h2("Choose a room on the left side.")); 26 | return this._root; 27 | } 28 | 29 | root() { 30 | return this._root; 31 | } 32 | 33 | unmount() {} 34 | update() {} 35 | } 36 | -------------------------------------------------------------------------------- /src/ui/web/session/RoomTile.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {TemplateView} from "../general/TemplateView.js"; 18 | 19 | export class RoomTile extends TemplateView { 20 | render(t) { 21 | return t.li([ 22 | t.div({className: "avatar medium"}, vm => vm.avatarInitials), 23 | t.div({className: "description"}, t.div({className: "name"}, vm => vm.name)) 24 | ]); 25 | } 26 | 27 | // called from ListView 28 | clicked() { 29 | this.value.open(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/matrix/storage/idb/stores/RoomSummaryStore.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | /** 18 | store contains: 19 | roomId 20 | name 21 | lastMessage 22 | unreadCount 23 | mentionCount 24 | isEncrypted 25 | isDirectMessage 26 | membership 27 | inviteCount 28 | joinCount 29 | */ 30 | export class RoomSummaryStore { 31 | constructor(summaryStore) { 32 | this._summaryStore = summaryStore; 33 | } 34 | 35 | getAll() { 36 | return this._summaryStore.selectAll(); 37 | } 38 | 39 | set(summary) { 40 | return this._summaryStore.put(summary); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/matrix/room/sending/PendingEvent.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | export class PendingEvent { 18 | constructor(data) { 19 | this._data = data; 20 | } 21 | 22 | get roomId() { return this._data.roomId; } 23 | get queueIndex() { return this._data.queueIndex; } 24 | get eventType() { return this._data.eventType; } 25 | get txnId() { return this._data.txnId; } 26 | get remoteId() { return this._data.remoteId; } 27 | set remoteId(value) { this._data.remoteId = value; } 28 | get content() { return this._data.content; } 29 | get data() { return this._data; } 30 | } 31 | -------------------------------------------------------------------------------- /src/ui/web/session/room/timeline/TextMessageView.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {TemplateView} from "../../../general/TemplateView.js"; 18 | 19 | export class TextMessageView extends TemplateView { 20 | render(t, vm) { 21 | return t.li( 22 | {className: {"TextMessageView": true, own: vm.isOwn, pending: vm.isPending}}, 23 | t.div({className: "message-container"}, [ 24 | t.div({className: "sender"}, vm => vm.isContinuation ? "" : vm.sender), 25 | t.p([vm.text, t.time(vm.date + " " + vm.time)]), 26 | ]) 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/ui/web/WebPlatform.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | export const WebPlatform = { 18 | get minStorageKey() { 19 | // for indexeddb, we use unsigned 32 bit integers as keys 20 | return 0; 21 | }, 22 | 23 | get middleStorageKey() { 24 | // for indexeddb, we use unsigned 32 bit integers as keys 25 | return 0x7FFFFFFF; 26 | }, 27 | 28 | get maxStorageKey() { 29 | // for indexeddb, we use unsigned 32 bit integers as keys 30 | return 0xFFFFFFFF; 31 | }, 32 | 33 | delay(ms) { 34 | return new Promise(resolve => setTimeout(resolve, ms)); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/matrix/storage/idb/stores/SessionStore.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | /** 18 | store contains: 19 | loginData { 20 | device_id 21 | home_server 22 | access_token 23 | user_id 24 | } 25 | // flags { 26 | // lazyLoading? 27 | // } 28 | syncToken 29 | displayName 30 | avatarUrl 31 | lastSynced 32 | */ 33 | export class SessionStore { 34 | constructor(sessionStore) { 35 | this._sessionStore = sessionStore; 36 | } 37 | 38 | async get() { 39 | const session = await this._sessionStore.selectFirst(IDBKeyRange.only(1)); 40 | if (session) { 41 | return session.value; 42 | } 43 | } 44 | 45 | set(session) { 46 | return this._sessionStore.put({key: 1, value: session}); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/ui/web/session/room/timeline/GapView.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {TemplateView} from "../../../general/TemplateView.js"; 18 | 19 | export class GapView extends TemplateView { 20 | render(t, vm) { 21 | const className = { 22 | GapView: true, 23 | isLoading: vm => vm.isLoading 24 | }; 25 | const label = (vm.isUp ? "🠝" : "🠟") + " fill gap"; //no binding 26 | return t.li({className}, [ 27 | t.button({ 28 | onClick: () => vm.fill(), 29 | disabled: vm => vm.isLoading 30 | }, label), 31 | t.if(vm => vm.error, t.createTemplate(t => t.strong(vm => vm.error))) 32 | ]); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/matrix/room/timeline/persistence/common.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | export function createEventEntry(key, roomId, event) { 18 | return { 19 | fragmentId: key.fragmentId, 20 | eventIndex: key.eventIndex, 21 | roomId, 22 | event: event, 23 | }; 24 | } 25 | 26 | export function directionalAppend(array, value, direction) { 27 | if (direction.isForward) { 28 | array.push(value); 29 | } else { 30 | array.unshift(value); 31 | } 32 | } 33 | 34 | export function directionalConcat(array, otherArray, direction) { 35 | if (direction.isForward) { 36 | return array.concat(otherArray); 37 | } else { 38 | return otherArray.concat(array); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/ui/web/session/SessionStatusView.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {TemplateView} from "../general/TemplateView.js"; 18 | import {spinner} from "../common.js"; 19 | 20 | export class SessionStatusView extends TemplateView { 21 | render(t, vm) { 22 | return t.div({className: { 23 | "SessionStatusView": true, 24 | "hidden": vm => !vm.isShown, 25 | }}, [ 26 | spinner(t, {hidden: vm => !vm.isWaiting}), 27 | t.p(vm => vm.statusLabel), 28 | t.if(vm => vm.isConnectNowShown, t.createTemplate(t => t.button({onClick: () => vm.connectNow()}, "Retry now"))), 29 | window.DEBUG ? t.button({id: "showlogs"}, "Show logs") : "" 30 | ]); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/ui/web/css/avatar.css: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | .avatar { 18 | --avatar-size: 32px; 19 | width: var(--avatar-size); 20 | height: var(--avatar-size); 21 | border-radius: 100px; 22 | overflow: hidden; 23 | flex-shrink: 0; 24 | -moz-user-select: none; 25 | -webkit-user-select: none; 26 | -ms-user-select: none; 27 | user-select: none; 28 | line-height: var(--avatar-size); 29 | font-size: calc(var(--avatar-size) * 0.6); 30 | text-align: center; 31 | letter-spacing: calc(var(--avatar-size) * -0.05); 32 | background: white; 33 | color: black; 34 | speak: none; 35 | } 36 | 37 | .avatar.large { 38 | --avatar-size: 40px; 39 | } 40 | 41 | .avatar img { 42 | width: 100%; 43 | height: 100%; 44 | } 45 | -------------------------------------------------------------------------------- /src/ui/web/session/room/MessageComposer.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {TemplateView} from "../../general/TemplateView.js"; 18 | 19 | export class MessageComposer extends TemplateView { 20 | constructor(viewModel) { 21 | super(viewModel); 22 | this._input = null; 23 | } 24 | 25 | render(t) { 26 | this._input = t.input({ 27 | placeholder: "Send a message ...", 28 | onKeydown: e => this._onKeyDown(e) 29 | }); 30 | return t.div({className: "MessageComposer"}, [this._input]); 31 | } 32 | 33 | _onKeyDown(event) { 34 | if (event.key === "Enter") { 35 | if (this.value.sendMessage(this._input.value)) { 36 | this._input.value = ""; 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/matrix/error.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | export class HomeServerError extends Error { 18 | constructor(method, url, body, status) { 19 | super(`${body ? body.error : status} on ${method} ${url}`); 20 | this.errcode = body ? body.errcode : null; 21 | this.retry_after_ms = body ? body.retry_after_ms : 0; 22 | this.statusCode = status; 23 | } 24 | 25 | get name() { 26 | return "HomeServerError"; 27 | } 28 | } 29 | 30 | export {AbortError} from "../utils/error.js"; 31 | 32 | export class ConnectionError extends Error { 33 | constructor(message, isTimeout) { 34 | super(message || "ConnectionError"); 35 | this.isTimeout = isTimeout; 36 | } 37 | 38 | get name() { 39 | return "ConnectionError"; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/matrix/room/timeline/Direction.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | export class Direction { 18 | constructor(isForward) { 19 | this._isForward = isForward; 20 | } 21 | 22 | get isForward() { 23 | return this._isForward; 24 | } 25 | 26 | get isBackward() { 27 | return !this.isForward; 28 | } 29 | 30 | asApiString() { 31 | return this.isForward ? "f" : "b"; 32 | } 33 | 34 | reverse() { 35 | return this.isForward ? Direction.Backward : Direction.Forward 36 | } 37 | 38 | static get Forward() { 39 | return _forward; 40 | } 41 | 42 | static get Backward() { 43 | return _backward; 44 | } 45 | } 46 | 47 | const _forward = Object.freeze(new Direction(true)); 48 | const _backward = Object.freeze(new Direction(false)); 49 | -------------------------------------------------------------------------------- /scripts/serve-local.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | const finalhandler = require('finalhandler') 18 | const http = require('http') 19 | const serveStatic = require('serve-static') 20 | const path = require('path'); 21 | 22 | // Serve up parent directory with cache disabled 23 | const serve = serveStatic( 24 | path.resolve(__dirname, "../"), 25 | { 26 | etag: false, 27 | setHeaders: res => { 28 | res.setHeader("Pragma", "no-cache"); 29 | res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); 30 | res.setHeader("Expires", "Wed, 21 Oct 2015 07:28:00 GMT"); 31 | }, 32 | index: ['index.html', 'index.htm'] 33 | } 34 | ); 35 | 36 | // Create server 37 | const server = http.createServer(function onRequest (req, res) { 38 | serve(req, res, finalhandler(req, res)) 39 | }); 40 | 41 | // Listen 42 | server.listen(3000); 43 | -------------------------------------------------------------------------------- /src/domain/session/room/timeline/UpdateAction.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | export class UpdateAction { 18 | constructor(remove, update, updateParams) { 19 | this._remove = remove; 20 | this._update = update; 21 | this._updateParams = updateParams; 22 | } 23 | 24 | get shouldRemove() { 25 | return this._remove; 26 | } 27 | 28 | get shouldUpdate() { 29 | return this._update; 30 | } 31 | 32 | get updateParams() { 33 | return this._updateParams; 34 | } 35 | 36 | static Remove() { 37 | return new UpdateAction(true, false, null); 38 | } 39 | 40 | static Update(newParams) { 41 | return new UpdateAction(false, true, newParams); 42 | } 43 | 44 | static Nothing() { 45 | return new UpdateAction(false, false, null); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/ui/web/css/spinner.css: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | @keyframes spinner { 18 | 0% { 19 | transform: rotate(0); 20 | stroke-dasharray: 0 0 10 90; 21 | } 22 | 45% { 23 | stroke-dasharray: 0 0 90 10; 24 | } 25 | 75% { 26 | stroke-dasharray: 0 50 50 0; 27 | } 28 | 100% { 29 | transform: rotate(360deg); 30 | stroke-dasharray: 10 90 0 0; 31 | } 32 | } 33 | 34 | .spinner circle { 35 | transform-origin: 50% 50%; 36 | animation-name: spinner; 37 | animation-duration: 2s; 38 | animation-iteration-count: infinite; 39 | animation-timing-function: linear; 40 | fill: none; 41 | stroke: currentcolor; 42 | stroke-width: 12; 43 | stroke-linecap: butt; 44 | } 45 | 46 | .spinner { 47 | --size: 20px; 48 | width: var(--size); 49 | height: var(--size); 50 | } 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This project is continuing at https://github.com/vector-im/hydrogen-web 2 | 3 | 4 | # Brawl 5 | 6 | A minimal [Matrix](https://matrix.org/) chat client, focused on performance and offline functionality. 7 | 8 | ## Status 9 | 10 | Brawl can currently log you in, or pick an existing session, sync already joined rooms, fill gaps in the timeline, and send text messages. Everything is stored locally. 11 | 12 | Here's an (outdated) GIF of what that looks like, also see link below to try it out: 13 | ![Showing multiple sessions, and sending messages](https://bwindels.github.io/brawl-chat/images/brawl-sending.gif) 14 | 15 | ## Why 16 | 17 | I started writing Brawl both to have a functional matrix client on my aging phone, and to play around with some ideas I had how to use indexeddb optimally in a matrix client. 18 | 19 | For every interaction or network response (syncing, filling a gap), Brawl starts a transaction in indexedb, and only commits it once everything went well. This helps to keep your storage always in a consistent state. As little data is kept in memory as well, and while scrolling in the above GIF, everything is loaded straight from the storage. 20 | 21 | If you find this interesting, feel free to reach me at `@bwindels:matrix.org`. 22 | 23 | # How to use 24 | 25 | You can [try Brawl here](https://bwindels.github.io/brawl/), or try it locally by running `yarn install` (only the first time) and `yarn start` in the terminal, and point your browser to `http://localhost:3000`. 26 | -------------------------------------------------------------------------------- /src/domain/session/room/timeline/tiles/LocationTile.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {MessageTile} from "./MessageTile.js"; 18 | 19 | /* 20 | map urls: 21 | apple: https://developer.apple.com/library/archive/featuredarticles/iPhoneURLScheme_Reference/MapLinks/MapLinks.html 22 | android: https://developers.google.com/maps/documentation/urls/guide 23 | wp: maps:49.275267 -122.988617 24 | https://www.habaneroconsulting.com/stories/insights/2011/opening-native-map-apps-from-the-mobile-browser 25 | */ 26 | export class LocationTile extends MessageTile { 27 | get mapsLink() { 28 | const geoUri = this._getContent().geo_uri; 29 | const [lat, long] = geoUri.split(":")[1].split(","); 30 | return `maps:${lat} ${long}`; 31 | } 32 | 33 | get label() { 34 | return `${this.sender} sent their location, click to see it in maps.`; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/observable/map/BaseObservableMap.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {BaseObservable} from "../BaseObservable.js"; 18 | 19 | export class BaseObservableMap extends BaseObservable { 20 | emitReset() { 21 | for(let h of this._handlers) { 22 | h.onReset(); 23 | } 24 | } 25 | // we need batch events, mostly on index based collection though? 26 | // maybe we should get started without? 27 | emitAdd(key, value) { 28 | for(let h of this._handlers) { 29 | h.onAdd(key, value); 30 | } 31 | } 32 | 33 | emitUpdate(key, value, ...params) { 34 | for(let h of this._handlers) { 35 | h.onUpdate(key, value, ...params); 36 | } 37 | } 38 | 39 | emitRemove(key, value) { 40 | for(let h of this._handlers) { 41 | h.onRemove(key, value); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/service-worker.template.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | const VERSION = "%%VERSION%%"; 18 | const FILES = "%%FILES%%"; 19 | const cacheName = `brawl-${VERSION}`; 20 | 21 | self.addEventListener('install', function(e) { 22 | e.waitUntil( 23 | caches.open(cacheName).then(function(cache) { 24 | return cache.addAll(FILES); 25 | }) 26 | ); 27 | }); 28 | 29 | self.addEventListener('activate', (event) => { 30 | event.waitUntil( 31 | caches.keys().then((keyList) => { 32 | return Promise.all(keyList.map((key) => { 33 | if (key !== cacheName) { 34 | return caches.delete(key); 35 | } 36 | })); 37 | }) 38 | ); 39 | }); 40 | 41 | self.addEventListener('fetch', (event) => { 42 | event.respondWith( 43 | caches.open(cacheName) 44 | .then(cache => cache.match(event.request)) 45 | .then((response) => response || fetch(event.request)) 46 | ); 47 | }); 48 | -------------------------------------------------------------------------------- /src/ui/web/dom/OnlineStatus.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {BaseObservableValue} from "../../../observable/ObservableValue.js"; 18 | 19 | export class OnlineStatus extends BaseObservableValue { 20 | constructor() { 21 | super(); 22 | this._onOffline = this._onOffline.bind(this); 23 | this._onOnline = this._onOnline.bind(this); 24 | } 25 | 26 | _onOffline() { 27 | this.emit(false); 28 | } 29 | 30 | _onOnline() { 31 | this.emit(true); 32 | } 33 | 34 | get value() { 35 | return navigator.onLine; 36 | } 37 | 38 | onSubscribeFirst() { 39 | window.addEventListener('offline', this._onOffline); 40 | window.addEventListener('online', this._onOnline); 41 | } 42 | 43 | onUnsubscribeLast() { 44 | window.removeEventListener('offline', this._onOffline); 45 | window.removeEventListener('online', this._onOnline); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/utils/sortedIndex.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | /** 18 | * @license 19 | * Based off baseSortedIndex function in Lodash 20 | * Copyright JS Foundation and other contributors 21 | * Released under MIT license 22 | * Based on Underscore.js 1.8.3 23 | * Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors 24 | */ 25 | export function sortedIndex(array, value, comparator) { 26 | let low = 0; 27 | let high = array.length; 28 | 29 | while (low < high) { 30 | let mid = (low + high) >>> 1; 31 | let cmpResult = comparator(value, array[mid]); 32 | 33 | if (cmpResult > 0) { 34 | low = mid + 1; 35 | } else if (cmpResult < 0) { 36 | high = mid; 37 | } else { 38 | low = high = mid; 39 | } 40 | } 41 | return high; 42 | } 43 | -------------------------------------------------------------------------------- /src/ui/web/css/left-panel.css: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | 18 | .LeftPanel { 19 | background: #333; 20 | color: white; 21 | overflow-y: auto; 22 | overscroll-behavior: contain; 23 | } 24 | 25 | .LeftPanel ul { 26 | list-style: none; 27 | padding: 0; 28 | margin: 0; 29 | } 30 | 31 | .LeftPanel li { 32 | margin: 5px; 33 | padding: 10px; 34 | display: flex; 35 | align-items: center; 36 | } 37 | 38 | .LeftPanel li { 39 | border-bottom: 1px #555 solid; 40 | } 41 | 42 | .LeftPanel li:last-child { 43 | border-bottom: none; 44 | } 45 | 46 | .LeftPanel li > * { 47 | margin-right: 10px; 48 | } 49 | 50 | .LeftPanel div.description { 51 | margin: 0; 52 | flex: 1 1 0; 53 | min-width: 0; 54 | } 55 | 56 | .LeftPanel .description > * { 57 | overflow: hidden; 58 | white-space: nowrap; 59 | text-overflow: ellipsis; 60 | } 61 | 62 | .LeftPanel .description .last-message { 63 | font-size: 0.8em; 64 | } 65 | -------------------------------------------------------------------------------- /src/ui/web/session/room/timeline/TimelineTile.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {tag} from "../../../general/html.js"; 18 | 19 | export class TimelineTile { 20 | constructor(tileVM) { 21 | this._tileVM = tileVM; 22 | this._root = null; 23 | } 24 | 25 | root() { 26 | return this._root; 27 | } 28 | 29 | mount() { 30 | this._root = renderTile(this._tileVM); 31 | return this._root; 32 | } 33 | 34 | unmount() {} 35 | 36 | update(vm, paramName) { 37 | } 38 | } 39 | 40 | function renderTile(tile) { 41 | switch (tile.shape) { 42 | case "message": 43 | return tag.li([tag.strong(tile.internalId+" "), tile.label]); 44 | case "announcement": 45 | return tag.li([tag.strong(tile.internalId+" "), tile.announcement]); 46 | default: 47 | return tag.li([tag.strong(tile.internalId+" "), "unknown tile shape: " + tile.shape]); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/domain/session/roomlist/RoomTileViewModel.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {avatarInitials} from "../avatar.js"; 18 | 19 | export class RoomTileViewModel { 20 | // we use callbacks to parent VM instead of emit because 21 | // it would be annoying to keep track of subscriptions in 22 | // parent for all RoomTileViewModels 23 | // emitUpdate is ObservableMap/ObservableList update mechanism 24 | constructor({room, emitUpdate, emitOpen}) { 25 | this._room = room; 26 | this._emitUpdate = emitUpdate; 27 | this._emitOpen = emitOpen; 28 | } 29 | 30 | open() { 31 | this._emitOpen(this._room); 32 | } 33 | 34 | compare(other) { 35 | // sort by name for now 36 | return this._room.name.localeCompare(other._room.name); 37 | } 38 | 39 | get name() { 40 | return this._room.name; 41 | } 42 | 43 | get avatarInitials() { 44 | return avatarInitials(this._room.name); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/matrix/storage/memory/stores/Store.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | export class Store { 18 | constructor(storeValue, writable) { 19 | this._storeValue = storeValue; 20 | this._writable = writable; 21 | } 22 | 23 | // makes a copy deep enough that any modifications in the store 24 | // won't affect the original 25 | // used for transactions 26 | cloneStoreValue() { 27 | // assumes 1 level deep is enough, and that values will be replaced 28 | // rather than updated. 29 | if (Array.isArray(this._storeValue)) { 30 | return this._storeValue.slice(); 31 | } else if (typeof this._storeValue === "object") { 32 | return Object.assign({}, this._storeValue); 33 | } else { 34 | return this._storeValue; 35 | } 36 | } 37 | 38 | assertWritable() { 39 | if (!this._writable) { 40 | throw new Error("Tried to write in read-only transaction"); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/ui/web/session/room/timeline/ImageView.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {TemplateView} from "../../../general/TemplateView.js"; 18 | 19 | export class ImageView extends TemplateView { 20 | render(t, vm) { 21 | const image = t.img({ 22 | src: vm.thumbnailUrl, 23 | width: vm.thumbnailWidth, 24 | height: vm.thumbnailHeight, 25 | loading: "lazy", 26 | style: `max-width: ${vm.thumbnailWidth}px`, 27 | alt: vm.label, 28 | }); 29 | const linkContainer = t.a({ 30 | href: vm.url, 31 | target: "_blank" 32 | }, image); 33 | 34 | return t.li( 35 | {className: {"TextMessageView": true, own: vm.isOwn, pending: vm.isPending}}, 36 | t.div({className: "message-container"}, [ 37 | t.div({className: "sender"}, vm => vm.isContinuation ? "" : vm.sender), 38 | t.div(linkContainer), 39 | t.p(t.time(vm.date + " " + vm.time)), 40 | ]) 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/observable/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {SortedMapList} from "./list/SortedMapList.js"; 18 | import {FilteredMap} from "./map/FilteredMap.js"; 19 | import {MappedMap} from "./map/MappedMap.js"; 20 | import {BaseObservableMap} from "./map/BaseObservableMap.js"; 21 | // re-export "root" (of chain) collections 22 | export { ObservableArray } from "./list/ObservableArray.js"; 23 | export { SortedArray } from "./list/SortedArray.js"; 24 | export { MappedList } from "./list/MappedList.js"; 25 | export { ConcatList } from "./list/ConcatList.js"; 26 | export { ObservableMap } from "./map/ObservableMap.js"; 27 | 28 | // avoid circular dependency between these classes 29 | // and BaseObservableMap (as they extend it) 30 | Object.assign(BaseObservableMap.prototype, { 31 | sortValues(comparator) { 32 | return new SortedMapList(this, comparator); 33 | }, 34 | 35 | mapValues(mapper) { 36 | return new MappedMap(this, mapper); 37 | }, 38 | 39 | filterValues(filter) { 40 | return new FilteredMap(this, filter); 41 | } 42 | }); 43 | -------------------------------------------------------------------------------- /src/matrix/storage/idb/export.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import { iterateCursor, txnAsPromise } from "./utils.js"; 18 | import { STORE_NAMES } from "../common.js"; 19 | 20 | export async function exportSession(db) { 21 | const NOT_DONE = {done: false}; 22 | const txn = db.transaction(STORE_NAMES, "readonly"); 23 | const data = {}; 24 | await Promise.all(STORE_NAMES.map(async name => { 25 | const results = data[name] = []; // initialize in deterministic order 26 | const store = txn.objectStore(name); 27 | await iterateCursor(store.openCursor(), (value) => { 28 | results.push(value); 29 | return NOT_DONE; 30 | }); 31 | })); 32 | return data; 33 | } 34 | 35 | export async function importSession(db, data) { 36 | const txn = db.transaction(STORE_NAMES, "readwrite"); 37 | for (const name of STORE_NAMES) { 38 | const store = txn.objectStore(name); 39 | for (const value of data[name]) { 40 | store.add(value); 41 | } 42 | } 43 | await txnAsPromise(txn); 44 | } 45 | -------------------------------------------------------------------------------- /src/matrix/storage/common.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | export const STORE_NAMES = Object.freeze([ 18 | "session", 19 | "roomState", 20 | "roomSummary", 21 | "timelineEvents", 22 | "timelineFragments", 23 | "pendingEvents", 24 | ]); 25 | 26 | export const STORE_MAP = Object.freeze(STORE_NAMES.reduce((nameMap, name) => { 27 | nameMap[name] = name; 28 | return nameMap; 29 | }, {})); 30 | 31 | export class StorageError extends Error { 32 | constructor(message, cause, value) { 33 | let fullMessage = message; 34 | if (cause) { 35 | fullMessage += ": "; 36 | if (typeof cause.name === "string") { 37 | fullMessage += `(name: ${cause.name}) `; 38 | } 39 | if (typeof cause.code === "number") { 40 | fullMessage += `(code: ${cause.name}) `; 41 | } 42 | fullMessage += cause.message; 43 | } 44 | super(fullMessage); 45 | if (cause) { 46 | this.errcode = cause.name; 47 | } 48 | this.cause = cause; 49 | this.value = value; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/observable/list/ObservableArray.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {BaseObservableList} from "./BaseObservableList.js"; 18 | 19 | export class ObservableArray extends BaseObservableList { 20 | constructor(initialValues = []) { 21 | super(); 22 | this._items = initialValues; 23 | } 24 | 25 | append(item) { 26 | this._items.push(item); 27 | this.emitAdd(this._items.length - 1, item); 28 | } 29 | 30 | insertMany(idx, items) { 31 | for(let item of items) { 32 | this.insert(idx, item); 33 | idx += 1; 34 | } 35 | } 36 | 37 | insert(idx, item) { 38 | this._items.splice(idx, 0, item); 39 | this.emitAdd(idx, item); 40 | } 41 | 42 | get array() { 43 | return this._items; 44 | } 45 | 46 | at(idx) { 47 | if (this._items && idx >= 0 && idx < this._items.length) { 48 | return this._items[idx]; 49 | } 50 | } 51 | 52 | get length() { 53 | return this._items.length; 54 | } 55 | 56 | [Symbol.iterator]() { 57 | return this._items.values(); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/utils/Disposables.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | function disposeValue(value) { 18 | if (typeof value === "function") { 19 | value(); 20 | } else { 21 | value.dispose(); 22 | } 23 | } 24 | 25 | export class Disposables { 26 | constructor() { 27 | this._disposables = []; 28 | } 29 | 30 | track(disposable) { 31 | this._disposables.push(disposable); 32 | } 33 | 34 | dispose() { 35 | if (this._disposables) { 36 | for (const d of this._disposables) { 37 | disposeValue(d); 38 | } 39 | this._disposables = null; 40 | } 41 | } 42 | 43 | disposeTracked(value) { 44 | if (value === undefined || value === null) { 45 | return null; 46 | } 47 | const idx = this._disposables.indexOf(value); 48 | if (idx !== -1) { 49 | const [foundValue] = this._disposables.splice(idx, 1); 50 | disposeValue(foundValue); 51 | } else { 52 | console.warn("disposable not found, did it leak?", value); 53 | } 54 | return null; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/matrix/room/timeline/entries/PendingEventEntry.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {BaseEntry, PENDING_FRAGMENT_ID} from "./BaseEntry.js"; 18 | 19 | export class PendingEventEntry extends BaseEntry { 20 | constructor({pendingEvent, user}) { 21 | super(null); 22 | this._pendingEvent = pendingEvent; 23 | this._user = user; 24 | } 25 | 26 | get fragmentId() { 27 | return PENDING_FRAGMENT_ID; 28 | } 29 | 30 | get entryIndex() { 31 | return this._pendingEvent.queueIndex; 32 | } 33 | 34 | get content() { 35 | return this._pendingEvent.content; 36 | } 37 | 38 | get event() { 39 | return null; 40 | } 41 | 42 | get eventType() { 43 | return this._pendingEvent.eventType; 44 | } 45 | 46 | get stateKey() { 47 | return null; 48 | } 49 | 50 | get sender() { 51 | return this._user.id; 52 | } 53 | 54 | get timestamp() { 55 | return null; 56 | } 57 | 58 | get isPending() { 59 | return true; 60 | } 61 | 62 | get id() { 63 | return this._pendingEvent.txnId; 64 | } 65 | 66 | notifyUpdate() { 67 | 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/matrix/room/timeline/entries/EventEntry.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {BaseEntry} from "./BaseEntry.js"; 18 | 19 | export class EventEntry extends BaseEntry { 20 | constructor(eventEntry, fragmentIdComparer) { 21 | super(fragmentIdComparer); 22 | this._eventEntry = eventEntry; 23 | } 24 | 25 | get fragmentId() { 26 | return this._eventEntry.fragmentId; 27 | } 28 | 29 | get entryIndex() { 30 | return this._eventEntry.eventIndex; 31 | } 32 | 33 | get content() { 34 | return this._eventEntry.event.content; 35 | } 36 | 37 | get prevContent() { 38 | const unsigned = this._eventEntry.event.unsigned; 39 | return unsigned && unsigned.prev_content; 40 | } 41 | 42 | get eventType() { 43 | return this._eventEntry.event.type; 44 | } 45 | 46 | get stateKey() { 47 | return this._eventEntry.event.state_key; 48 | } 49 | 50 | get sender() { 51 | return this._eventEntry.event.sender; 52 | } 53 | 54 | get timestamp() { 55 | return this._eventEntry.event.origin_server_ts; 56 | } 57 | 58 | get id() { 59 | return this._eventEntry.event.event_id; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/matrix/room/timeline/entries/BaseEntry.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | //entries can be sorted, first by fragment, then by entry index. 18 | import {EventKey} from "../EventKey.js"; 19 | export const PENDING_FRAGMENT_ID = Number.MAX_SAFE_INTEGER; 20 | 21 | export class BaseEntry { 22 | constructor(fragmentIdComparer) { 23 | this._fragmentIdComparer = fragmentIdComparer; 24 | } 25 | 26 | get fragmentId() { 27 | throw new Error("unimplemented"); 28 | } 29 | 30 | get entryIndex() { 31 | throw new Error("unimplemented"); 32 | } 33 | 34 | compare(otherEntry) { 35 | if (this.fragmentId === otherEntry.fragmentId) { 36 | return this.entryIndex - otherEntry.entryIndex; 37 | } else if (this.fragmentId === PENDING_FRAGMENT_ID) { 38 | return 1; 39 | } else if (otherEntry.fragmentId === PENDING_FRAGMENT_ID) { 40 | return -1; 41 | } else { 42 | // This might throw if the relation of two fragments is unknown. 43 | return this._fragmentIdComparer.compare(this.fragmentId, otherEntry.fragmentId); 44 | } 45 | } 46 | 47 | asEventKey() { 48 | return new EventKey(this.fragmentId, this.entryIndex); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /doc/impl-thoughts/VIEW-UPDATES.md: -------------------------------------------------------------------------------- 1 | # View updates 2 | 3 | ## Current situation 4 | 5 | - arguments of View.update are not standardized, it's either: 6 | - name of property that was updated on viewmodel 7 | - names of property that was updated on viewmodel 8 | - map of updated values 9 | - we have 2 update mechanisms: 10 | - listening on viewmodel change event 11 | - through ObservableCollection which parent view listens on and calls `update(newValue)` on the child view. This is an optimization to prevent every view in a collection to need to subscribe and unsubscribe to a viewmodel. 12 | 13 | - should updates on a template value propagate to subviews? 14 | - either a view listens on the view model, ... 15 | - or waits for updates from parent view: 16 | - item view in a list view 17 | - subtemplate (not needed, we could just have 2 subscriptions!!) 18 | 19 | ok, we always subscribe in a (sub)template. But for example RoomTile and it's viewmodel; RoomTileViewModel doesn't extend EventEmitter or ObservableValue today because it (would) emit(s) updates through the parent collection. So today it's view would not subscribe to it. But if it wants to extend ViewModel to have all the other infrastructure, you'd receive double updates. 20 | 21 | I think we might need to make it explicit whether or not the parent will provide updates for the children or not. Maybe as a mount() parameter? Yeah, I like that. ListView would pass in `true`. Most other things would pass in `false`/`undefined`. `Template` can then choose to bind or not based on that param. 22 | 23 | Should we make a base/subclass of Template that does not do event binding to save a few bytes in memory for the event subscription fields that are not needed? Not now, this is less ergonimic, and a small optimization. We can always do that later, and we'd just have to replace the base class of the few views that appear in a `ListView`. 24 | -------------------------------------------------------------------------------- /src/ui/web/css/layout.css: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | html { 18 | height: 100%; 19 | } 20 | body { 21 | margin: 0; 22 | } 23 | 24 | .SessionView { 25 | display: flex; 26 | flex-direction: column; 27 | height: 100vh; 28 | } 29 | 30 | .SessionView > .main { 31 | flex: 1; 32 | display: flex; 33 | min-height: 0; 34 | min-width: 0; 35 | width: 100vw; 36 | } 37 | 38 | /* mobile layout */ 39 | @media screen and (max-width: 800px) { 40 | .RoomHeader button.back { display: block; } 41 | div.RoomView, div.RoomPlaceholderView { display: none; } 42 | div.LeftPanel {flex-grow: 1;} 43 | div.room-shown div.RoomView { display: flex; } 44 | div.room-shown div.LeftPanel { display: none; } 45 | div.right-shown div.TimelinePanel { display: none; } 46 | } 47 | 48 | .LeftPanel { 49 | flex: 0 0 300px; 50 | min-width: 0; 51 | } 52 | 53 | .RoomPlaceholderView, .RoomView { 54 | flex: 1 0 0; 55 | min-width: 0; 56 | } 57 | 58 | .RoomView { 59 | min-width: 0; 60 | display: flex; 61 | } 62 | 63 | 64 | .TimelinePanel { 65 | flex: 3; 66 | min-height: 0; 67 | min-width: 0; 68 | display: flex; 69 | flex-direction: column; 70 | height: 100%; 71 | } 72 | 73 | .TimelinePanel ul { 74 | flex: 1 0 0; 75 | } 76 | 77 | .RoomHeader { 78 | display: flex; 79 | } 80 | -------------------------------------------------------------------------------- /doc/impl-thoughts/RELATIONS.md: -------------------------------------------------------------------------------- 1 | Relations and redactions 2 | 3 | events that refer to another event will need support in the SyncWriter, Timeline and SendQueue I think. 4 | SyncWriter will need to resolve the related remote id to a [fragmentId, eventIndex] and persist that on the event that relates to some other. Same for SendQueue? If unknown remote id, not much to do. However, once the remote id comes in, how do we handle it correctly? We might need a index on m.relates_to/event_id? 5 | 6 | The timeline can take incoming events from both the SendQueue and SyncWriter, and see if their related to fragmentId/eventIndex is in view, and then update it? 7 | 8 | alternatively, SyncWriter/SendQueue could have a section with updatedEntries apart from newEntries? 9 | 10 | SendQueue will need to pass the non-sent state (redactions & relations) about an event that has it's remote echo received to the SyncWriter so it doesn't flash while redactions and relations for it still have to be synced. 11 | 12 | Also, related ids should be processed recursively. If event 3 is a redaction of event 2, a reaction to event 1, all 3 entries should be considered as updated. 13 | 14 | As a UI for reactions, we could show (👍 14 + 1) where the + 1 is our own local echo (perhaps style it pulsating and/or in grey?). Clicking it again would just show 14 and when the remote echo comes in it would turn into 15. 15 | 16 | 17 | 18 | wrt to how to store relations in indexeddb, we could store all local ids of related events (per type?) on the related-to event, so we can fetch them in one query for *all* events that have related events that were fetched in a range, without needing another index that would slow down writes. So that would only add 1 query which we only need to do when there are relations in the TimelineReader. what do we do though if we receive the relating event before the related-to event? An index would fix this mostly ... or we need a temp store where we store unresolved relations... 19 | -------------------------------------------------------------------------------- /src/observable/list/BaseObservableList.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {BaseObservable} from "../BaseObservable.js"; 18 | 19 | export class BaseObservableList extends BaseObservable { 20 | emitReset() { 21 | for(let h of this._handlers) { 22 | h.onReset(this); 23 | } 24 | } 25 | // we need batch events, mostly on index based collection though? 26 | // maybe we should get started without? 27 | emitAdd(index, value) { 28 | for(let h of this._handlers) { 29 | h.onAdd(index, value, this); 30 | } 31 | } 32 | 33 | emitUpdate(index, value, params) { 34 | for(let h of this._handlers) { 35 | h.onUpdate(index, value, params, this); 36 | } 37 | } 38 | 39 | emitRemove(index, value) { 40 | for(let h of this._handlers) { 41 | h.onRemove(index, value, this); 42 | } 43 | } 44 | 45 | // toIdx assumes the item has already 46 | // been removed from its fromIdx 47 | emitMove(fromIdx, toIdx, value) { 48 | for(let h of this._handlers) { 49 | h.onMove(fromIdx, toIdx, value, this); 50 | } 51 | } 52 | 53 | [Symbol.iterator]() { 54 | throw new Error("unimplemented"); 55 | } 56 | 57 | get length() { 58 | throw new Error("unimplemented"); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/matrix/storage/memory/Storage.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {Transaction} from "./Transaction.js"; 18 | import { STORE_MAP, STORE_NAMES } from "../common.js"; 19 | 20 | export class Storage { 21 | constructor(initialStoreValues = {}) { 22 | this._validateStoreNames(Object.keys(initialStoreValues)); 23 | this.storeNames = STORE_MAP; 24 | this._storeValues = STORE_NAMES.reduce((values, name) => { 25 | values[name] = initialStoreValues[name] || null; 26 | }, {}); 27 | } 28 | 29 | _validateStoreNames(storeNames) { 30 | const idx = storeNames.findIndex(name => !STORE_MAP.hasOwnProperty(name)); 31 | if (idx !== -1) { 32 | throw new Error(`Invalid store name ${storeNames[idx]}`); 33 | } 34 | } 35 | 36 | _createTxn(storeNames, writable) { 37 | this._validateStoreNames(storeNames); 38 | const storeValues = storeNames.reduce((values, name) => { 39 | return values[name] = this._storeValues[name]; 40 | }, {}); 41 | return Promise.resolve(new Transaction(storeValues, writable)); 42 | } 43 | 44 | readTxn(storeNames) { 45 | // TODO: avoid concurrency 46 | return this._createTxn(storeNames, false); 47 | } 48 | 49 | readWriteTxn(storeNames) { 50 | // TODO: avoid concurrency 51 | return this._createTxn(storeNames, true); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/domain/session/room/timeline/tiles/MessageTile.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {SimpleTile} from "./SimpleTile.js"; 18 | 19 | export class MessageTile extends SimpleTile { 20 | constructor(options) { 21 | super(options); 22 | this._isOwn = this._entry.sender === options.ownUserId; 23 | this._date = new Date(this._entry.timestamp); 24 | this._isContinuation = false; 25 | } 26 | 27 | get shape() { 28 | return "message"; 29 | } 30 | 31 | get sender() { 32 | return this._entry.sender; 33 | } 34 | 35 | get date() { 36 | return this._date.toLocaleDateString({}, {month: "numeric", day: "numeric"}); 37 | } 38 | 39 | get time() { 40 | return this._date.toLocaleTimeString({}, {hour: "numeric", minute: "2-digit"}); 41 | } 42 | 43 | get isOwn() { 44 | return this._isOwn; 45 | } 46 | 47 | get isContinuation() { 48 | return this._isContinuation; 49 | } 50 | 51 | _getContent() { 52 | return this._entry.content; 53 | } 54 | 55 | updatePreviousSibling(prev) { 56 | super.updatePreviousSibling(prev); 57 | const isContinuation = prev && prev instanceof MessageTile && prev.sender === this.sender; 58 | if (isContinuation !== this._isContinuation) { 59 | this._isContinuation = isContinuation; 60 | this.emitUpdate("isContinuation"); 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/ui/web/session/room/RoomView.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {TemplateView} from "../../general/TemplateView.js"; 18 | import {TimelineList} from "./TimelineList.js"; 19 | import {MessageComposer} from "./MessageComposer.js"; 20 | 21 | export class RoomView extends TemplateView { 22 | constructor(viewModel) { 23 | super(viewModel); 24 | this._timelineList = null; 25 | } 26 | 27 | render(t, vm) { 28 | this._timelineList = new TimelineList(); 29 | return t.div({className: "RoomView"}, [ 30 | t.div({className: "TimelinePanel"}, [ 31 | t.div({className: "RoomHeader"}, [ 32 | t.button({className: "back", onClick: () => vm.close()}), 33 | t.div({className: "avatar large"}, vm => vm.avatarInitials), 34 | t.div({className: "room-description"}, [ 35 | t.h2(vm => vm.name), 36 | ]), 37 | ]), 38 | t.div({className: "RoomView_error"}, vm => vm.error), 39 | t.view(this._timelineList), 40 | t.view(new MessageComposer(this.value.composerViewModel)), 41 | ]) 42 | ]); 43 | } 44 | 45 | update(value, prop) { 46 | super.update(value, prop); 47 | if (prop === "timelineViewModel") { 48 | this._timelineList.update({viewModel: this.value.timelineViewModel}); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/ui/web/css/room.css: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | 18 | .RoomHeader { 19 | padding: 10px; 20 | background-color: #333; 21 | } 22 | 23 | .RoomHeader > *:last-child { 24 | margin-right: 0; 25 | } 26 | 27 | .RoomHeader > * { 28 | margin-right: 10px; 29 | flex: 0 0 auto; 30 | } 31 | 32 | .RoomHeader button { 33 | width: 40px; 34 | height: 40px; 35 | display: none; 36 | font-size: 1.5em; 37 | padding: 0; 38 | display: block; 39 | background: white; 40 | border: none; 41 | font-weight: bolder; 42 | line-height: 40px; 43 | } 44 | 45 | .RoomHeader .back { 46 | display: none; 47 | } 48 | 49 | .RoomHeader .room-description { 50 | flex: 1; 51 | min-width: 0; 52 | } 53 | 54 | .RoomHeader .topic { 55 | font-size: 0.8em; 56 | overflow: hidden; 57 | white-space: nowrap; 58 | text-overflow: ellipsis; 59 | } 60 | 61 | .back::before { 62 | content: "☰"; 63 | } 64 | 65 | .more::before { 66 | content: "⋮"; 67 | } 68 | 69 | .RoomHeader { 70 | align-items: center; 71 | } 72 | 73 | .RoomHeader .description { 74 | flex: 1 1 auto; 75 | min-width: 0; 76 | } 77 | 78 | .RoomHeader h2 { 79 | overflow: hidden; 80 | white-space: nowrap; 81 | text-overflow: ellipsis; 82 | margin: 0; 83 | } 84 | 85 | .RoomView_error { 86 | color: red; 87 | } 88 | 89 | .MessageComposer > input { 90 | display: block; 91 | width: 100%; 92 | box-sizing: border-box; 93 | padding: 0.8em; 94 | border: none; 95 | } 96 | -------------------------------------------------------------------------------- /src/ui/web/login/LoginView.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {TemplateView} from "../general/TemplateView.js"; 18 | import {brawlGithubLink} from "./common.js"; 19 | import {SessionLoadView} from "./SessionLoadView.js"; 20 | 21 | export class LoginView extends TemplateView { 22 | render(t, vm) { 23 | const disabled = vm => !!vm.isBusy; 24 | const username = t.input({type: "text", placeholder: vm.i18n`Username`, disabled}); 25 | const password = t.input({type: "password", placeholder: vm.i18n`Password`, disabled}); 26 | const homeserver = t.input({type: "text", placeholder: vm.i18n`Your matrix homeserver`, value: vm.defaultHomeServer, disabled}); 27 | return t.div({className: "LoginView form"}, [ 28 | t.h1([vm.i18n`Log in to your homeserver`]), 29 | t.if(vm => vm.error, t.createTemplate(t => t.div({className: "error"}, vm => vm.error))), 30 | t.div(username), 31 | t.div(password), 32 | t.div(homeserver), 33 | t.div(t.button({ 34 | onClick: () => vm.login(username.value, password.value, homeserver.value), 35 | disabled 36 | }, vm.i18n`Log In`)), 37 | t.div(t.button({onClick: () => vm.cancel(), disabled}, [vm.i18n`Pick an existing session`])), 38 | t.mapView(vm => vm.loadViewModel, loadViewModel => loadViewModel ? new SessionLoadView(loadViewModel) : null), 39 | t.p(brawlGithubLink(t)) 40 | ]); 41 | } 42 | } 43 | 44 | -------------------------------------------------------------------------------- /doc/QUESTIONS.md: -------------------------------------------------------------------------------- 1 | remaining problems to resolve: 2 | 3 | how to store timelime fragments that we don't yet know how they should be sorted wrt the other events and gaps. the case with event permalinks and showing the replied to event when rendering a reply (anything from /context). 4 | 5 | either we could put timeline pieces that were the result of /context in something that is not the timeline. Gaps also don't really make sense there ... You can just paginate backwards and forwards. Or maybe still in the timeline but in a different scope not part of the sortKey, scope: live, or scope: piece-1204. While paginating, we could keep the start and end event_id of all the scopes in memory, and set a marker on them to stitch them together? 6 | 7 | Hmmm, I can see the usefullness of the concept of timeline set with multiple timelines in it for this. for the live timeline it's less convenient as you're not bothered so much by the stitching up, but for /context pieces that run into the live timeline while paginating it seems more useful... we could have a marker entry that refers to the next or previous scope ... this way we could also use gap entries for /context timelines, just one on either end. 8 | 9 | the start and end event_id of a scope, keeping that in memory, how do we make sure this is safe taking transactions into account? our preferred strategy so far has been to read everything from store inside a txn to make sure we don't have any stale caches or races. Would be nice to keep this. 10 | 11 | so while paginating, you'd check the event_id of the event against the start/end event_id of every scope to see if stitching is in order, and add marker entries if so. Perhaps marker entries could also be used to stitch up rooms that have changed versioning? 12 | 13 | What does all of this mean for using sortKey as an identifier? Will we need to take scope into account as well everywhere? 14 | 15 | we'll need to at least contemplate how room state will be handled with all of the above. 16 | 17 | how do we deal with the fact that an event can be rendered (and updated) multiple times in the timeline as part of replies. 18 | 19 | room state... 20 | -------------------------------------------------------------------------------- /src/matrix/storage/idb/Storage.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {Transaction} from "./Transaction.js"; 18 | import { STORE_NAMES, StorageError } from "../common.js"; 19 | 20 | export class Storage { 21 | constructor(idbDatabase) { 22 | this._db = idbDatabase; 23 | const nameMap = STORE_NAMES.reduce((nameMap, name) => { 24 | nameMap[name] = name; 25 | return nameMap; 26 | }, {}); 27 | this.storeNames = Object.freeze(nameMap); 28 | } 29 | 30 | _validateStoreNames(storeNames) { 31 | const idx = storeNames.findIndex(name => !STORE_NAMES.includes(name)); 32 | if (idx !== -1) { 33 | throw new StorageError(`Tried top, a transaction unknown store ${storeNames[idx]}`); 34 | } 35 | } 36 | 37 | async readTxn(storeNames) { 38 | this._validateStoreNames(storeNames); 39 | try { 40 | const txn = this._db.transaction(storeNames, "readonly"); 41 | return new Transaction(txn, storeNames); 42 | } catch(err) { 43 | throw new StorageError("readTxn failed", err); 44 | } 45 | } 46 | 47 | async readWriteTxn(storeNames) { 48 | this._validateStoreNames(storeNames); 49 | try { 50 | const txn = this._db.transaction(storeNames, "readwrite"); 51 | return new Transaction(txn, storeNames); 52 | } catch(err) { 53 | throw new StorageError("readWriteTxn failed", err); 54 | } 55 | } 56 | 57 | close() { 58 | this._db.close(); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/domain/session/room/timeline/tiles/ImageTile.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {MessageTile} from "./MessageTile.js"; 18 | 19 | const MAX_HEIGHT = 300; 20 | const MAX_WIDTH = 400; 21 | 22 | export class ImageTile extends MessageTile { 23 | constructor(options, room) { 24 | super(options); 25 | this._room = room; 26 | } 27 | 28 | get thumbnailUrl() { 29 | const mxcUrl = this._getContent().url; 30 | return this._room.mxcUrlThumbnail(mxcUrl, this.thumbnailWidth, this.thumbnailHeight, "scale"); 31 | } 32 | 33 | get url() { 34 | const mxcUrl = this._getContent().url; 35 | return this._room.mxcUrl(mxcUrl); 36 | } 37 | 38 | _scaleFactor() { 39 | const {info} = this._getContent(); 40 | const scaleHeightFactor = MAX_HEIGHT / info.h; 41 | const scaleWidthFactor = MAX_WIDTH / info.w; 42 | // take the smallest scale factor, to respect all constraints 43 | // we should not upscale images, so limit scale factor to 1 upwards 44 | return Math.min(scaleWidthFactor, scaleHeightFactor, 1); 45 | } 46 | 47 | get thumbnailWidth() { 48 | const {info} = this._getContent(); 49 | return Math.round(info.w * this._scaleFactor()); 50 | } 51 | 52 | get thumbnailHeight() { 53 | const {info} = this._getContent(); 54 | return Math.round(info.h * this._scaleFactor()); 55 | } 56 | 57 | get label() { 58 | return this._getContent().body; 59 | } 60 | 61 | get shape() { 62 | return "image"; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/observable/BaseObservable.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | export class BaseObservable { 18 | constructor() { 19 | this._handlers = new Set(); 20 | } 21 | 22 | onSubscribeFirst() { 23 | 24 | } 25 | 26 | onUnsubscribeLast() { 27 | 28 | } 29 | 30 | subscribe(handler) { 31 | this._handlers.add(handler); 32 | if (this._handlers.size === 1) { 33 | this.onSubscribeFirst(); 34 | } 35 | return () => { 36 | return this.unsubscribe(handler); 37 | }; 38 | } 39 | 40 | unsubscribe(handler) { 41 | if (handler) { 42 | this._handlers.delete(handler); 43 | if (this._handlers.size === 0) { 44 | this.onUnsubscribeLast(); 45 | } 46 | handler = null; 47 | } 48 | return null; 49 | } 50 | 51 | // Add iterator over handlers here 52 | } 53 | 54 | export function tests() { 55 | class Collection extends BaseObservable { 56 | constructor() { 57 | super(); 58 | this.firstSubscribeCalls = 0; 59 | this.firstUnsubscribeCalls = 0; 60 | } 61 | onSubscribeFirst() { this.firstSubscribeCalls += 1; } 62 | onUnsubscribeLast() { this.firstUnsubscribeCalls += 1; } 63 | } 64 | 65 | return { 66 | test_unsubscribe(assert) { 67 | const c = new Collection(); 68 | const unsubscribe = c.subscribe({}); 69 | unsubscribe(); 70 | assert.equal(c.firstSubscribeCalls, 1); 71 | assert.equal(c.firstUnsubscribeCalls, 1); 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /doc/api.md: -------------------------------------------------------------------------------- 1 | Session 2 | properties: 3 | rooms -> Rooms 4 | 5 | # storage 6 | Storage 7 | key...() -> KeyRange 8 | start...Txn() -> Transaction 9 | Transaction 10 | store(name) -> ObjectStore 11 | finish() 12 | rollback() 13 | ObjectStore : QueryTarget 14 | index(name) 15 | Index : QueryTarget 16 | 17 | 18 | Rooms: EventEmitter, Iterator 19 | get(id) -> RoomSummary ? 20 | InternalRoom: EventEmitter 21 | applySync(roomResponse, membership, txn) 22 | - this method updates the room summary 23 | - persists the room summary 24 | - persists room state & timeline with RoomPersister 25 | - updates the OpenRoom if present 26 | 27 | 28 | applyAndPersistSync(roomResponse, membership, txn) { 29 | this._summary.applySync(roomResponse, membership); 30 | this._summary.persist(txn); 31 | this._roomPersister.persist(roomResponse, membership, txn); 32 | if (this._openRoom) { 33 | this._openRoom.applySync(roomResponse); 34 | } 35 | } 36 | 37 | RoomPersister 38 | RoomPersister (persists timeline and room state) 39 | RoomSummary (persists room summary) 40 | RoomSummary : EventEmitter 41 | methods: 42 | async open() 43 | id 44 | name 45 | lastMessage 46 | unreadCount 47 | mentionCount 48 | isEncrypted 49 | isDirectMessage 50 | membership 51 | 52 | should this have a custom reducer for custom fields? 53 | 54 | events 55 | propChange(fieldName) 56 | 57 | OpenRoom : EventEmitter 58 | properties: 59 | timeline 60 | events: 61 | 62 | 63 | RoomState: EventEmitter 64 | [room_id, event_type, state_key] -> [sort_key, event] 65 | Timeline: EventEmitter 66 | // should have a cache of recently lookup sender members? 67 | // can we disambiguate members like this? 68 | methods: 69 | lastEvents(amount) 70 | firstEvents(amount) 71 | eventsAfter(sortKey, amount) 72 | eventsBefore(sortKey, amount) 73 | events: 74 | eventsApppended 75 | 76 | RoomMembers : EventEmitter, Iterator 77 | // no order, but need to be able to get all members somehow, needs to map to a ReactiveMap or something 78 | events: 79 | added(ids, values) 80 | removed(ids, values) 81 | changed(id, fieldName) 82 | RoomMember: EventEmitter 83 | properties: 84 | id 85 | name 86 | powerLevel 87 | membership 88 | avatar 89 | events: 90 | propChange(fieldName) -------------------------------------------------------------------------------- /src/matrix/sessioninfo/localstorage/SessionInfoStorage.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | export class SessionInfoStorage { 18 | constructor(name) { 19 | this._name = name; 20 | } 21 | 22 | getAll() { 23 | const sessionsJson = localStorage.getItem(this._name); 24 | if (sessionsJson) { 25 | const sessions = JSON.parse(sessionsJson); 26 | if (Array.isArray(sessions)) { 27 | return Promise.resolve(sessions); 28 | } 29 | } 30 | return Promise.resolve([]); 31 | } 32 | 33 | async hasAnySession() { 34 | const all = await this.getAll(); 35 | return all && all.length > 0; 36 | } 37 | 38 | async updateLastUsed(id, timestamp) { 39 | const sessions = await this.getAll(); 40 | if (sessions) { 41 | const session = sessions.find(session => session.id === id); 42 | if (session) { 43 | session.lastUsed = timestamp; 44 | localStorage.setItem(this._name, JSON.stringify(sessions)); 45 | } 46 | } 47 | } 48 | 49 | async get(id) { 50 | const sessions = await this.getAll(); 51 | if (sessions) { 52 | return sessions.find(session => session.id === id); 53 | } 54 | } 55 | 56 | async add(sessionInfo) { 57 | const sessions = await this.getAll(); 58 | sessions.push(sessionInfo); 59 | localStorage.setItem(this._name, JSON.stringify(sessions)); 60 | } 61 | 62 | async delete(sessionId) { 63 | let sessions = await this.getAll(); 64 | sessions = sessions.filter(s => s.id !== sessionId); 65 | localStorage.setItem(this._name, JSON.stringify(sessions)); 66 | } 67 | 68 | } 69 | -------------------------------------------------------------------------------- /src/ui/web/dom/Clock.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {AbortError} from "../../../utils/error.js"; 18 | 19 | class Timeout { 20 | constructor(ms) { 21 | this._reject = null; 22 | this._handle = null; 23 | this._promise = new Promise((resolve, reject) => { 24 | this._reject = reject; 25 | this._handle = setTimeout(() => { 26 | this._reject = null; 27 | resolve(); 28 | }, ms); 29 | }); 30 | } 31 | 32 | elapsed() { 33 | return this._promise; 34 | } 35 | 36 | abort() { 37 | if (this._reject) { 38 | this._reject(new AbortError()); 39 | clearTimeout(this._handle); 40 | this._handle = null; 41 | this._reject = null; 42 | } 43 | } 44 | 45 | dispose() { 46 | this.abort(); 47 | } 48 | } 49 | 50 | class Interval { 51 | constructor(ms, callback) { 52 | this._handle = setInterval(callback, ms); 53 | } 54 | 55 | dispose() { 56 | if (this._handle) { 57 | clearInterval(this._handle); 58 | this._handle = null; 59 | } 60 | } 61 | } 62 | 63 | 64 | class TimeMeasure { 65 | constructor() { 66 | this._start = window.performance.now(); 67 | } 68 | 69 | measure() { 70 | return window.performance.now() - this._start; 71 | } 72 | } 73 | 74 | export class Clock { 75 | createMeasure() { 76 | return new TimeMeasure(); 77 | } 78 | 79 | createTimeout(ms) { 80 | return new Timeout(ms); 81 | } 82 | 83 | createInterval(callback, ms) { 84 | return new Interval(ms, callback); 85 | } 86 | 87 | now() { 88 | return Date.now(); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/observable/map/FilteredMap.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {BaseObservableMap} from "./BaseObservableMap.js"; 18 | 19 | export class FilteredMap extends BaseObservableMap { 20 | constructor(source, mapper, updater) { 21 | super(); 22 | this._source = source; 23 | this._mapper = mapper; 24 | this._updater = updater; 25 | this._mappedValues = new Map(); 26 | } 27 | 28 | onAdd(key, value) { 29 | const mappedValue = this._mapper(value); 30 | this._mappedValues.set(key, mappedValue); 31 | this.emitAdd(key, mappedValue); 32 | } 33 | 34 | onRemove(key, _value) { 35 | const mappedValue = this._mappedValues.get(key); 36 | if (this._mappedValues.delete(key)) { 37 | this.emitRemove(key, mappedValue); 38 | } 39 | } 40 | 41 | onChange(key, value, params) { 42 | const mappedValue = this._mappedValues.get(key); 43 | if (mappedValue !== undefined) { 44 | const newParams = this._updater(value, params); 45 | if (newParams !== undefined) { 46 | this.emitChange(key, mappedValue, newParams); 47 | } 48 | } 49 | } 50 | 51 | onSubscribeFirst() { 52 | for (let [key, value] of this._source) { 53 | const mappedValue = this._mapper(value); 54 | this._mappedValues.set(key, mappedValue); 55 | } 56 | super.onSubscribeFirst(); 57 | } 58 | 59 | onUnsubscribeLast() { 60 | super.onUnsubscribeLast(); 61 | this._mappedValues.clear(); 62 | } 63 | 64 | onReset() { 65 | this._mappedValues.clear(); 66 | this.emitReset(); 67 | } 68 | 69 | [Symbol.iterator]() { 70 | return this._mappedValues.entries()[Symbol.iterator]; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/domain/session/room/timeline/tiles/GapTile.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {SimpleTile} from "./SimpleTile.js"; 18 | import {UpdateAction} from "../UpdateAction.js"; 19 | 20 | export class GapTile extends SimpleTile { 21 | constructor(options, timeline) { 22 | super(options); 23 | this._timeline = timeline; 24 | this._loading = false; 25 | this._error = null; 26 | } 27 | 28 | async fill() { 29 | // prevent doing this twice 30 | if (!this._loading) { 31 | this._loading = true; 32 | this.emitUpdate("isLoading"); 33 | try { 34 | await this._timeline.fillGap(this._entry, 10); 35 | } catch (err) { 36 | console.error(`timeline.fillGap(): ${err.message}:\n${err.stack}`); 37 | this._error = err; 38 | this.emitUpdate("error"); 39 | } finally { 40 | this._loading = false; 41 | this.emitUpdate("isLoading"); 42 | } 43 | } 44 | } 45 | 46 | updateEntry(entry, params) { 47 | super.updateEntry(entry, params); 48 | if (!entry.isGap) { 49 | return UpdateAction.Remove(); 50 | } else { 51 | return UpdateAction.Nothing(); 52 | } 53 | } 54 | 55 | get shape() { 56 | return "gap"; 57 | } 58 | 59 | get isLoading() { 60 | return this._loading; 61 | } 62 | 63 | get isUp() { 64 | return this._entry.direction.isBackward; 65 | } 66 | 67 | get isDown() { 68 | return this._entry.direction.isForward; 69 | } 70 | 71 | get error() { 72 | if (this._error) { 73 | const dir = this._entry.prev_batch ? "previous" : "next"; 74 | return `Could not load ${dir} messages: ${this._error.message}`; 75 | } 76 | return null; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 17 | 30 | 34 | 44 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /src/domain/session/room/timeline/tilesCreator.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {GapTile} from "./tiles/GapTile.js"; 18 | import {TextTile} from "./tiles/TextTile.js"; 19 | import {ImageTile} from "./tiles/ImageTile.js"; 20 | import {LocationTile} from "./tiles/LocationTile.js"; 21 | import {RoomNameTile} from "./tiles/RoomNameTile.js"; 22 | import {RoomMemberTile} from "./tiles/RoomMemberTile.js"; 23 | 24 | export function tilesCreator({room, ownUserId}) { 25 | return function tilesCreator(entry, emitUpdate) { 26 | const options = {entry, emitUpdate, ownUserId}; 27 | if (entry.isGap) { 28 | return new GapTile(options, room); 29 | } else if (entry.eventType) { 30 | switch (entry.eventType) { 31 | case "m.room.message": { 32 | const content = entry.content; 33 | const msgtype = content && content.msgtype; 34 | switch (msgtype) { 35 | case "m.text": 36 | case "m.notice": 37 | case "m.emote": 38 | return new TextTile(options); 39 | case "m.image": 40 | return new ImageTile(options, room); 41 | case "m.location": 42 | return new LocationTile(options); 43 | default: 44 | // unknown msgtype not rendered 45 | return null; 46 | } 47 | } 48 | case "m.room.name": 49 | return new RoomNameTile(options); 50 | case "m.room.member": 51 | return new RoomMemberTile(options); 52 | default: 53 | // unknown type not rendered 54 | return null; 55 | } 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /prototypes/chrome-keys.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 73 | 74 | -------------------------------------------------------------------------------- /src/observable/list/SortedArray.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {BaseObservableList} from "./BaseObservableList.js"; 18 | import {sortedIndex} from "../../utils/sortedIndex.js"; 19 | 20 | export class SortedArray extends BaseObservableList { 21 | constructor(comparator) { 22 | super(); 23 | this._comparator = comparator; 24 | this._items = []; 25 | } 26 | 27 | setManyUnsorted(items) { 28 | this.setManySorted(items); 29 | } 30 | 31 | setManySorted(items) { 32 | // TODO: we can make this way faster by only looking up the first and last key, 33 | // and merging whatever is inbetween with items 34 | // if items is not sorted, 💩🌀 will follow! 35 | // should we check? 36 | // Also, once bulk events are supported in collections, 37 | // we can do a bulk add event here probably if there are no updates 38 | // BAD CODE! 39 | for(let item of items) { 40 | this.set(item); 41 | } 42 | } 43 | 44 | set(item, updateParams = null) { 45 | const idx = sortedIndex(this._items, item, this._comparator); 46 | if (idx >= this._items.length || this._comparator(this._items[idx], item) !== 0) { 47 | this._items.splice(idx, 0, item); 48 | this.emitAdd(idx, item) 49 | } else { 50 | this._items[idx] = item; 51 | this.emitUpdate(idx, item, updateParams); 52 | } 53 | } 54 | 55 | get(idx) { 56 | return this._items[idx]; 57 | } 58 | 59 | remove(idx) { 60 | const item = this._items[idx]; 61 | this._items.splice(idx, 1); 62 | this.emitRemove(idx, item); 63 | } 64 | 65 | get array() { 66 | return this._items; 67 | } 68 | 69 | get length() { 70 | return this._items.length; 71 | } 72 | 73 | [Symbol.iterator]() { 74 | return this._items.values(); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/domain/session/room/timeline/TimelineViewModel.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | /* 18 | need better naming, but 19 | entry = event or gap from matrix layer 20 | tile = item on visual timeline like event, date separator?, group of joined events 21 | 22 | 23 | shall we put date separators as marker in EventViewItem or separate item? binary search will be complicated ... 24 | 25 | 26 | pagination ... 27 | 28 | on the timeline viewmodel (containing the TilesCollection?) we'll have a method to (un)load a tail or head of 29 | the timeline (counted in tiles), which results to a range in sortKeys we want on the screen. We pass that range 30 | to the room timeline, which unload entries from memory. 31 | when loading, it just reads events from a sortkey backwards or forwards... 32 | */ 33 | import {TilesCollection} from "./TilesCollection.js"; 34 | import {tilesCreator} from "./tilesCreator.js"; 35 | 36 | export class TimelineViewModel { 37 | constructor({room, timeline, ownUserId}) { 38 | this._timeline = timeline; 39 | // once we support sending messages we could do 40 | // timeline.entries.concat(timeline.pendingEvents) 41 | // for an ObservableList that also contains local echos 42 | this._tiles = new TilesCollection(timeline.entries, tilesCreator({room, ownUserId})); 43 | } 44 | 45 | // doesn't fill gaps, only loads stored entries/tiles 46 | loadAtTop() { 47 | return this._timeline.loadAtTop(50); 48 | } 49 | 50 | unloadAtTop(tileAmount) { 51 | // get lowerSortKey for tile at index tileAmount - 1 52 | // tell timeline to unload till there (included given key) 53 | } 54 | 55 | loadAtBottom() { 56 | 57 | } 58 | 59 | unloadAtBottom(tileAmount) { 60 | // get upperSortKey for tile at index tiles.length - tileAmount 61 | // tell timeline to unload till there (included given key) 62 | } 63 | 64 | get tiles() { 65 | return this._tiles; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/ui/web/css/timeline.css: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | 18 | .TimelinePanel ul { 19 | overflow-y: auto; 20 | overscroll-behavior: contain; 21 | list-style: none; 22 | padding: 0; 23 | margin: 0; 24 | } 25 | 26 | .TimelinePanel li { 27 | } 28 | 29 | .message-container { 30 | flex: 0 1 auto; 31 | max-width: 80%; 32 | padding: 5px 10px; 33 | margin: 5px 10px; 34 | background: blue; 35 | /* first try break-all, then break-word, which isn't supported everywhere */ 36 | word-break: break-all; 37 | word-break: break-word; 38 | } 39 | 40 | .message-container .sender { 41 | margin: 5px 0; 42 | font-size: 0.9em; 43 | font-weight: bold; 44 | } 45 | 46 | .message-container img { 47 | display: block; 48 | width: 100%; 49 | height: auto; 50 | } 51 | 52 | .TextMessageView { 53 | display: flex; 54 | min-width: 0; 55 | } 56 | 57 | .TextMessageView.own .message-container { 58 | margin-left: auto; 59 | } 60 | 61 | .TextMessageView .message-container time { 62 | float: right; 63 | padding: 2px 0 0px 20px; 64 | font-size: 0.9em; 65 | color: lightblue; 66 | } 67 | 68 | .message-container time { 69 | font-size: 0.9em; 70 | color: lightblue; 71 | } 72 | 73 | .own time { 74 | color: lightgreen; 75 | } 76 | 77 | .own .message-container { 78 | background-color: darkgreen; 79 | } 80 | 81 | .TextMessageView.pending .message-container { 82 | background-color: #333; 83 | } 84 | 85 | .message-container p { 86 | margin: 5px 0; 87 | } 88 | 89 | .AnnouncementView { 90 | margin: 5px 0; 91 | padding: 5px 10%; 92 | display: flex; 93 | align-items: center; 94 | } 95 | 96 | .AnnouncementView > div { 97 | margin: 0 auto; 98 | padding: 10px 20px; 99 | background-color: #333; 100 | font-size: 0.9em; 101 | color: #CCC; 102 | text-align: center; 103 | } 104 | -------------------------------------------------------------------------------- /prototypes/idb-multi-key.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /src/domain/session/room/timeline/tiles/RoomMemberTile.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {SimpleTile} from "./SimpleTile.js"; 18 | 19 | export class RoomMemberTile extends SimpleTile { 20 | 21 | get shape() { 22 | return "announcement"; 23 | } 24 | 25 | get announcement() { 26 | const {sender, content, prevContent, stateKey} = this._entry; 27 | const membership = content && content.membership; 28 | const prevMembership = prevContent && prevContent.membership; 29 | 30 | if (prevMembership === "join" && membership === "join") { 31 | if (content.avatar_url !== prevContent.avatar_url) { 32 | return `${stateKey} changed their avatar`; 33 | } else if (content.displayname !== prevContent.displayname) { 34 | return `${stateKey} changed their name to ${content.displayname}`; 35 | } 36 | } else if (membership === "join") { 37 | return `${stateKey} joined the room`; 38 | } else if (membership === "invite") { 39 | return `${stateKey} was invited to the room by ${sender}`; 40 | } else if (prevMembership === "invite") { 41 | if (membership === "join") { 42 | return `${stateKey} accepted the invitation to join the room`; 43 | } else if (membership === "leave") { 44 | return `${stateKey} declined the invitation to join the room`; 45 | } 46 | } else if (membership === "leave") { 47 | if (stateKey === sender) { 48 | return `${stateKey} left the room`; 49 | } else { 50 | const reason = content.reason; 51 | return `${stateKey} was kicked from the room by ${sender}${reason ? `: ${reason}` : ""}`; 52 | } 53 | } else if (membership === "ban") { 54 | return `${stateKey} was banned from the room by ${sender}`; 55 | } 56 | 57 | return `${sender} membership changed to ${content.membership}`; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/matrix/storage/idb/stores/PendingEventStore.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import { encodeUint32, decodeUint32 } from "../utils.js"; 18 | import {Platform} from "../../../../Platform.js"; 19 | 20 | function encodeKey(roomId, queueIndex) { 21 | return `${roomId}|${encodeUint32(queueIndex)}`; 22 | } 23 | 24 | function decodeKey(key) { 25 | const [roomId, encodedQueueIndex] = key.split("|"); 26 | const queueIndex = decodeUint32(encodedQueueIndex); 27 | return {roomId, queueIndex}; 28 | } 29 | 30 | export class PendingEventStore { 31 | constructor(eventStore) { 32 | this._eventStore = eventStore; 33 | } 34 | 35 | async getMaxQueueIndex(roomId) { 36 | const range = IDBKeyRange.bound( 37 | encodeKey(roomId, Platform.minStorageKey), 38 | encodeKey(roomId, Platform.maxStorageKey), 39 | false, 40 | false, 41 | ); 42 | const maxKey = await this._eventStore.findMaxKey(range); 43 | if (maxKey) { 44 | return decodeKey(maxKey).queueIndex; 45 | } 46 | } 47 | 48 | remove(roomId, queueIndex) { 49 | const keyRange = IDBKeyRange.only(encodeKey(roomId, queueIndex)); 50 | this._eventStore.delete(keyRange); 51 | } 52 | 53 | async exists(roomId, queueIndex) { 54 | const keyRange = IDBKeyRange.only(encodeKey(roomId, queueIndex)); 55 | let key; 56 | if (this._eventStore.supports("getKey")) { 57 | key = await this._eventStore.getKey(keyRange); 58 | } else { 59 | const value = await this._eventStore.get(keyRange); 60 | key = value && value.key; 61 | } 62 | return !!key; 63 | } 64 | 65 | add(pendingEvent) { 66 | pendingEvent.key = encodeKey(pendingEvent.roomId, pendingEvent.queueIndex); 67 | return this._eventStore.add(pendingEvent); 68 | } 69 | 70 | update(pendingEvent) { 71 | return this._eventStore.put(pendingEvent); 72 | } 73 | 74 | getAll() { 75 | return this._eventStore.selectAll(); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/matrix/storage/memory/Transaction.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {RoomTimelineStore} from "./stores/RoomTimelineStore.js"; 18 | 19 | export class Transaction { 20 | constructor(storeValues, writable) { 21 | this._storeValues = storeValues; 22 | this._txnStoreValues = {}; 23 | this._writable = writable; 24 | } 25 | 26 | _store(name, mapper) { 27 | if (!this._txnStoreValues.hasOwnProperty(name)) { 28 | if (!this._storeValues.hasOwnProperty(name)) { 29 | throw new Error(`Transaction wasn't opened for store ${name}`); 30 | } 31 | const store = mapper(this._storeValues[name]); 32 | const clone = store.cloneStoreValue(); 33 | // extra prevention for writing 34 | if (!this._writable) { 35 | Object.freeze(clone); 36 | } 37 | this._txnStoreValues[name] = clone; 38 | } 39 | return mapper(this._txnStoreValues[name]); 40 | } 41 | 42 | get session() { 43 | throw new Error("not yet implemented"); 44 | // return this._store("session", storeValue => new SessionStore(storeValue)); 45 | } 46 | 47 | get roomSummary() { 48 | throw new Error("not yet implemented"); 49 | // return this._store("roomSummary", storeValue => new RoomSummaryStore(storeValue)); 50 | } 51 | 52 | get roomTimeline() { 53 | return this._store("roomTimeline", storeValue => new RoomTimelineStore(storeValue)); 54 | } 55 | 56 | get roomState() { 57 | throw new Error("not yet implemented"); 58 | // return this._store("roomState", storeValue => new RoomStateStore(storeValue)); 59 | } 60 | 61 | complete() { 62 | for(let name of Object.keys(this._txnStoreValues)) { 63 | this._storeValues[name] = this._txnStoreValues[name]; 64 | } 65 | this._txnStoreValues = null; 66 | return Promise.resolve(); 67 | } 68 | 69 | abort() { 70 | this._txnStoreValues = null; 71 | return Promise.resolve(); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/ui/web/BrawlView.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {SessionView} from "./session/SessionView.js"; 18 | import {LoginView} from "./login/LoginView.js"; 19 | import {SessionPickerView} from "./login/SessionPickerView.js"; 20 | import {TemplateView} from "./general/TemplateView.js"; 21 | import {SwitchView} from "./general/SwitchView.js"; 22 | 23 | export class BrawlView { 24 | constructor(vm) { 25 | this._vm = vm; 26 | this._switcher = null; 27 | this._root = null; 28 | this._onViewModelChange = this._onViewModelChange.bind(this); 29 | } 30 | 31 | _getView() { 32 | switch (this._vm.activeSection) { 33 | case "error": 34 | return new StatusView({header: "Something went wrong", message: this._vm.errorText}); 35 | case "session": 36 | return new SessionView(this._vm.sessionViewModel); 37 | case "login": 38 | return new LoginView(this._vm.loginViewModel); 39 | case "picker": 40 | return new SessionPickerView(this._vm.sessionPickerViewModel); 41 | default: 42 | throw new Error(`Unknown section: ${this._vm.activeSection}`); 43 | } 44 | } 45 | 46 | _onViewModelChange(prop) { 47 | if (prop === "activeSection") { 48 | this._switcher.switch(this._getView()); 49 | } 50 | } 51 | 52 | mount() { 53 | this._switcher = new SwitchView(this._getView()); 54 | this._root = this._switcher.mount(); 55 | this._vm.on("change", this._onViewModelChange); 56 | return this._root; 57 | } 58 | 59 | unmount() { 60 | this._vm.off("change", this._onViewModelChange); 61 | this._switcher.unmount(); 62 | } 63 | 64 | root() { 65 | return this._root; 66 | } 67 | 68 | update() {} 69 | } 70 | 71 | class StatusView extends TemplateView { 72 | render(t, vm) { 73 | return t.div({className: "StatusView"}, [ 74 | t.h1(vm.header), 75 | t.p(vm.message), 76 | ]); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/ui/web/view-gallery.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 26 |

    View Gallery

    27 |

    Session Status Bar

    28 |
    29 | 40 |

    Login

    41 |
    42 | 51 |

    Login Loading

    52 |
    53 | 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /src/domain/session/room/timeline/tiles/SimpleTile.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {UpdateAction} from "../UpdateAction.js"; 18 | 19 | export class SimpleTile { 20 | constructor({entry}) { 21 | this._entry = entry; 22 | this._emitUpdate = null; 23 | } 24 | // view model props for all subclasses 25 | // hmmm, could also do instanceof ... ? 26 | get shape() { 27 | return null; 28 | // "gap" | "message" | "image" | ... ? 29 | } 30 | 31 | // don't show display name / avatar 32 | // probably only for MessageTiles of some sort? 33 | get isContinuation() { 34 | return false; 35 | } 36 | 37 | get hasDateSeparator() { 38 | return false; 39 | } 40 | 41 | emitUpdate(paramName) { 42 | if (this._emitUpdate) { 43 | this._emitUpdate(this, paramName); 44 | } 45 | } 46 | 47 | get internalId() { 48 | return this._entry.asEventKey().toString(); 49 | } 50 | 51 | get isPending() { 52 | return this._entry.isPending; 53 | } 54 | // TilesCollection contract below 55 | setUpdateEmit(emitUpdate) { 56 | this._emitUpdate = emitUpdate; 57 | } 58 | 59 | get upperEntry() { 60 | return this._entry; 61 | } 62 | 63 | get lowerEntry() { 64 | return this._entry; 65 | } 66 | 67 | compareEntry(entry) { 68 | return this._entry.compare(entry); 69 | } 70 | 71 | // update received for already included (falls within sort keys) entry 72 | updateEntry(entry) { 73 | this._entry = entry; 74 | return UpdateAction.Nothing(); 75 | } 76 | 77 | // return whether the tile should be removed 78 | // as SimpleTile only has one entry, the tile should be removed 79 | removeEntry(entry) { 80 | return true; 81 | } 82 | 83 | // SimpleTile can only contain 1 entry 84 | tryIncludeEntry() { 85 | return false; 86 | } 87 | // let item know it has a new sibling 88 | updatePreviousSibling(prev) { 89 | 90 | } 91 | 92 | // let item know it has a new sibling 93 | updateNextSibling(next) { 94 | 95 | } 96 | // TilesCollection contract above 97 | } 98 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // import {RecordRequester, ReplayRequester} from "./matrix/net/request/replay.js"; 18 | import {fetchRequest} from "./matrix/net/request/fetch.js"; 19 | import {SessionContainer} from "./matrix/SessionContainer.js"; 20 | import {StorageFactory} from "./matrix/storage/idb/StorageFactory.js"; 21 | import {SessionInfoStorage} from "./matrix/sessioninfo/localstorage/SessionInfoStorage.js"; 22 | import {BrawlViewModel} from "./domain/BrawlViewModel.js"; 23 | import {BrawlView} from "./ui/web/BrawlView.js"; 24 | import {Clock} from "./ui/web/dom/Clock.js"; 25 | import {OnlineStatus} from "./ui/web/dom/OnlineStatus.js"; 26 | 27 | export default async function main(container) { 28 | try { 29 | // to replay: 30 | // const fetchLog = await (await fetch("/fetchlogs/constrainterror.json")).json(); 31 | // const replay = new ReplayRequester(fetchLog, {delay: false}); 32 | // const request = replay.request; 33 | 34 | // to record: 35 | // const recorder = new RecordRequester(fetchRequest); 36 | // const request = recorder.request; 37 | // window.getBrawlFetchLog = () => recorder.log(); 38 | // normal network: 39 | const request = fetchRequest; 40 | const sessionInfoStorage = new SessionInfoStorage("brawl_sessions_v1"); 41 | const clock = new Clock(); 42 | const storageFactory = new StorageFactory(); 43 | 44 | const vm = new BrawlViewModel({ 45 | createSessionContainer: () => { 46 | return new SessionContainer({ 47 | random: Math.random, 48 | onlineStatus: new OnlineStatus(), 49 | storageFactory, 50 | sessionInfoStorage, 51 | request, 52 | clock, 53 | }); 54 | }, 55 | sessionInfoStorage, 56 | storageFactory, 57 | clock, 58 | }); 59 | window.__brawlViewModel = vm; 60 | await vm.load(); 61 | const view = new BrawlView(vm); 62 | container.appendChild(view.mount()); 63 | } catch(err) { 64 | console.error(`${err.message}:\n${err.stack}`); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/matrix/storage/idb/StorageFactory.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {Storage} from "./Storage.js"; 18 | import { openDatabase, reqAsPromise } from "./utils.js"; 19 | import { exportSession, importSession } from "./export.js"; 20 | 21 | const sessionName = sessionId => `brawl_session_${sessionId}`; 22 | const openDatabaseWithSessionId = sessionId => openDatabase(sessionName(sessionId), createStores, 1); 23 | 24 | export class StorageFactory { 25 | async create(sessionId) { 26 | const db = await openDatabaseWithSessionId(sessionId); 27 | return new Storage(db); 28 | } 29 | 30 | delete(sessionId) { 31 | const databaseName = sessionName(sessionId); 32 | const req = window.indexedDB.deleteDatabase(databaseName); 33 | return reqAsPromise(req); 34 | } 35 | 36 | async export(sessionId) { 37 | const db = await openDatabaseWithSessionId(sessionId); 38 | return await exportSession(db); 39 | } 40 | 41 | async import(sessionId, data) { 42 | const db = await openDatabaseWithSessionId(sessionId); 43 | return await importSession(db, data); 44 | } 45 | } 46 | 47 | function createStores(db) { 48 | db.createObjectStore("session", {keyPath: "key"}); 49 | // any way to make keys unique here? (just use put?) 50 | db.createObjectStore("roomSummary", {keyPath: "roomId"}); 51 | 52 | // need index to find live fragment? prooobably ok without for now 53 | //key = room_id | fragment_id 54 | db.createObjectStore("timelineFragments", {keyPath: "key"}); 55 | //key = room_id | fragment_id | event_index 56 | const timelineEvents = db.createObjectStore("timelineEvents", {keyPath: "key"}); 57 | //eventIdKey = room_id | event_id 58 | timelineEvents.createIndex("byEventId", "eventIdKey", {unique: true}); 59 | //key = room_id | event.type | event.state_key, 60 | db.createObjectStore("roomState", {keyPath: "key"}); 61 | db.createObjectStore("pendingEvents", {keyPath: "key"}); 62 | 63 | // const roomMembers = db.createObjectStore("roomMembers", {keyPath: [ 64 | // "event.room_id", 65 | // "event.content.membership", 66 | // "event.state_key" 67 | // ]}); 68 | // roomMembers.createIndex("byName", ["room_id", "content.name"]); 69 | } 70 | -------------------------------------------------------------------------------- /src/ui/web/css/main.css: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | @import url('layout.css'); 18 | @import url('login.css'); 19 | @import url('left-panel.css'); 20 | @import url('room.css'); 21 | @import url('timeline.css'); 22 | @import url('avatar.css'); 23 | @import url('spinner.css'); 24 | 25 | .brawl { 26 | margin: 0; 27 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu, Cantarell, sans-serif, 28 | "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 29 | background-color: black; 30 | color: white; 31 | /* make sure to disable rubber-banding and pull to refresh in a PWA if we'd end up having a scrollbar */ 32 | overscroll-behavior: none; 33 | } 34 | 35 | .hiddenWithLayout { 36 | visibility: hidden; 37 | } 38 | 39 | .hidden { 40 | display: none !important; 41 | } 42 | 43 | .SessionStatusView { 44 | display: flex; 45 | padding: 5px; 46 | background-color: #555; 47 | } 48 | 49 | .SessionStatusView p { 50 | margin: 0 10px; 51 | word-break: break-all; 52 | word-break: break-word; 53 | } 54 | 55 | .SessionStatusView button { 56 | border: none; 57 | background: none; 58 | color: currentcolor; 59 | text-decoration: underline; 60 | } 61 | 62 | 63 | .RoomPlaceholderView { 64 | display: flex; 65 | align-items: center; 66 | justify-content: center; 67 | flex-direction: row; 68 | } 69 | 70 | .SessionPickerView { 71 | padding: 0.4em; 72 | } 73 | 74 | .SessionPickerView ul { 75 | list-style: none; 76 | padding: 0; 77 | } 78 | 79 | .SessionPickerView li { 80 | margin: 0.4em 0; 81 | font-size: 1.2em; 82 | background-color: grey; 83 | padding: 0.5em; 84 | } 85 | 86 | .SessionPickerView .sessionInfo { 87 | cursor: pointer; 88 | display: flex; 89 | } 90 | 91 | .SessionPickerView li span.userId { 92 | flex: 1; 93 | } 94 | 95 | .SessionPickerView li span.error { 96 | margin: 0 20px; 97 | } 98 | 99 | .LoginView { 100 | padding: 0.4em; 101 | } 102 | 103 | a { 104 | color: white; 105 | } 106 | 107 | .form > div { 108 | margin: 0.4em 0; 109 | } 110 | 111 | .form input { 112 | display: block; 113 | width: 100%; 114 | box-sizing: border-box; 115 | } 116 | 117 | -------------------------------------------------------------------------------- /src/domain/ViewModel.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // ViewModel should just be an eventemitter, not an ObservableValue 18 | // as in some cases it would really be more convenient to have multiple events (like telling the timeline to scroll down) 19 | // we do need to return a disposable from EventEmitter.on, or at least have a method here to easily track a subscription to an EventEmitter 20 | 21 | import {EventEmitter} from "../utils/EventEmitter.js"; 22 | import {Disposables} from "../utils/Disposables.js"; 23 | 24 | export class ViewModel extends EventEmitter { 25 | constructor({clock} = {}) { 26 | super(); 27 | this.disposables = null; 28 | this._options = {clock}; 29 | } 30 | 31 | childOptions(explicitOptions) { 32 | return Object.assign({}, this._options, explicitOptions); 33 | } 34 | 35 | track(disposable) { 36 | if (!this.disposables) { 37 | this.disposables = new Disposables(); 38 | } 39 | this.disposables.track(disposable); 40 | return disposable; 41 | } 42 | 43 | dispose() { 44 | if (this.disposables) { 45 | this.disposables.dispose(); 46 | } 47 | } 48 | 49 | disposeTracked(disposable) { 50 | if (this.disposables) { 51 | return this.disposables.disposeTracked(disposable); 52 | } 53 | return null; 54 | } 55 | 56 | // TODO: this will need to support binding 57 | // if any of the expr is a function, assume the function is a binding, and return a binding function ourselves 58 | // 59 | // translated string should probably always be bindings, unless we're fine with a refresh when changing the language? 60 | // we probably are, if we're using routing with a url, we could just refresh. 61 | i18n(parts, ...expr) { 62 | // just concat for now 63 | let result = ""; 64 | for (let i = 0; i < parts.length; ++i) { 65 | result = result + parts[i]; 66 | if (i < expr.length) { 67 | result = result + expr[i]; 68 | } 69 | } 70 | return result; 71 | } 72 | 73 | emitChange(changedProps) { 74 | this.emit("change", changedProps); 75 | } 76 | 77 | get clock() { 78 | return this._options.clock; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/domain/session/SessionViewModel.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {RoomTileViewModel} from "./roomlist/RoomTileViewModel.js"; 18 | import {RoomViewModel} from "./room/RoomViewModel.js"; 19 | import {SessionStatusViewModel} from "./SessionStatusViewModel.js"; 20 | import {ViewModel} from "../ViewModel.js"; 21 | 22 | export class SessionViewModel extends ViewModel { 23 | constructor(options) { 24 | super(options); 25 | const {sessionContainer} = options; 26 | this._session = sessionContainer.session; 27 | this._sessionStatusViewModel = this.track(new SessionStatusViewModel(this.childOptions({ 28 | sync: sessionContainer.sync, 29 | reconnector: sessionContainer.reconnector 30 | }))); 31 | this._currentRoomViewModel = null; 32 | const roomTileVMs = this._session.rooms.mapValues((room, emitUpdate) => { 33 | return new RoomTileViewModel({ 34 | room, 35 | emitUpdate, 36 | emitOpen: room => this._openRoom(room) 37 | }); 38 | }); 39 | this._roomList = roomTileVMs.sortValues((a, b) => a.compare(b)); 40 | } 41 | 42 | start() { 43 | this._sessionStatusViewModel.start(); 44 | } 45 | 46 | get sessionStatusViewModel() { 47 | return this._sessionStatusViewModel; 48 | } 49 | 50 | get roomList() { 51 | return this._roomList; 52 | } 53 | 54 | get currentRoom() { 55 | return this._currentRoomViewModel; 56 | } 57 | 58 | _closeCurrentRoom() { 59 | if (this._currentRoomViewModel) { 60 | this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel); 61 | this.emitChange("currentRoom"); 62 | } 63 | } 64 | 65 | _openRoom(room) { 66 | if (this._currentRoomViewModel) { 67 | this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel); 68 | } 69 | this._currentRoomViewModel = this.track(new RoomViewModel(this.childOptions({ 70 | room, 71 | ownUserId: this._session.user.id, 72 | closeCallback: () => this._closeCurrentRoom(), 73 | }))); 74 | this._currentRoomViewModel.load(); 75 | this.emitChange("currentRoom"); 76 | } 77 | } 78 | 79 | -------------------------------------------------------------------------------- /src/ui/web/general/SwitchView.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {errorToDOM} from "./error.js"; 18 | 19 | export class SwitchView { 20 | constructor(defaultView) { 21 | this._childView = defaultView; 22 | } 23 | 24 | mount() { 25 | return this._childView.mount(); 26 | } 27 | 28 | unmount() { 29 | return this._childView.unmount(); 30 | } 31 | 32 | root() { 33 | return this._childView.root(); 34 | } 35 | 36 | update() { 37 | return this._childView.update(); 38 | } 39 | 40 | switch(newView) { 41 | const oldRoot = this.root(); 42 | this._childView.unmount(); 43 | this._childView = newView; 44 | let newRoot; 45 | try { 46 | newRoot = this._childView.mount(); 47 | } catch (err) { 48 | newRoot = errorToDOM(err); 49 | } 50 | const parent = oldRoot.parentElement; 51 | if (parent) { 52 | parent.replaceChild(newRoot, oldRoot); 53 | } 54 | } 55 | 56 | get childView() { 57 | return this._childView; 58 | } 59 | } 60 | /* 61 | // SessionLoadView 62 | // should this be the new switch view? 63 | // and the other one be the BasicSwitchView? 64 | new BoundSwitchView(vm, vm => vm.isLoading, (loading, vm) => { 65 | if (loading) { 66 | return new InlineTemplateView(vm, t => { 67 | return t.div({className: "loading"}, [ 68 | t.span({className: "spinner"}), 69 | t.span(vm => vm.loadingText) 70 | ]); 71 | }); 72 | } else { 73 | return new SessionView(vm.sessionViewModel); 74 | } 75 | }); 76 | */ 77 | export class BoundSwitchView extends SwitchView { 78 | constructor(value, mapper, viewCreator) { 79 | super(viewCreator(mapper(value), value)); 80 | this._mapper = mapper; 81 | this._viewCreator = viewCreator; 82 | this._mappedValue = mapper(value); 83 | } 84 | 85 | update(value) { 86 | const mappedValue = this._mapper(value); 87 | if (mappedValue !== this._mappedValue) { 88 | this._mappedValue = mappedValue; 89 | this.switch(this._viewCreator(this._mappedValue, value)); 90 | } else { 91 | super.update(value); 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/observable/map/MappedMap.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {BaseObservableMap} from "./BaseObservableMap.js"; 18 | /* 19 | so a mapped value can emit updates on it's own with this._updater that is passed in the mapping function 20 | how should the mapped value be notified of an update though? and can it then decide to not propagate the update? 21 | */ 22 | export class MappedMap extends BaseObservableMap { 23 | constructor(source, mapper) { 24 | super(); 25 | this._source = source; 26 | this._mapper = mapper; 27 | this._mappedValues = new Map(); 28 | this._updater = (key, params) => { // this should really be (value, params) but can't make that work for now 29 | const value = this._mappedValues.get(key); 30 | this.onUpdate(key, value, params); 31 | }; 32 | } 33 | 34 | onAdd(key, value) { 35 | const mappedValue = this._mapper(value, this._updater); 36 | this._mappedValues.set(key, mappedValue); 37 | this.emitAdd(key, mappedValue); 38 | } 39 | 40 | onRemove(key, _value) { 41 | const mappedValue = this._mappedValues.get(key); 42 | if (this._mappedValues.delete(key)) { 43 | this.emitRemove(key, mappedValue); 44 | } 45 | } 46 | 47 | onUpdate(key, value, params) { 48 | const mappedValue = this._mappedValues.get(key); 49 | if (mappedValue !== undefined) { 50 | const newParams = this._updater(value, params); 51 | // if (newParams !== undefined) { 52 | this.emitUpdate(key, mappedValue, newParams); 53 | // } 54 | } 55 | } 56 | 57 | onSubscribeFirst() { 58 | this._subscription = this._source.subscribe(this); 59 | for (let [key, value] of this._source) { 60 | const mappedValue = this._mapper(value, this._updater); 61 | this._mappedValues.set(key, mappedValue); 62 | } 63 | super.onSubscribeFirst(); 64 | } 65 | 66 | onUnsubscribeLast() { 67 | this._subscription = this._subscription(); 68 | this._mappedValues.clear(); 69 | } 70 | 71 | onReset() { 72 | this._mappedValues.clear(); 73 | this.emitReset(); 74 | } 75 | 76 | [Symbol.iterator]() { 77 | return this._mappedValues.entries(); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/matrix/storage/idb/stores/TimelineFragmentStore.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import { StorageError } from "../../common.js"; 18 | import {Platform} from "../../../../Platform.js"; 19 | import { encodeUint32 } from "../utils.js"; 20 | 21 | function encodeKey(roomId, fragmentId) { 22 | return `${roomId}|${encodeUint32(fragmentId)}`; 23 | } 24 | 25 | export class TimelineFragmentStore { 26 | constructor(store) { 27 | this._store = store; 28 | } 29 | 30 | _allRange(roomId) { 31 | try { 32 | return IDBKeyRange.bound( 33 | encodeKey(roomId, Platform.minStorageKey), 34 | encodeKey(roomId, Platform.maxStorageKey) 35 | ); 36 | } catch (err) { 37 | throw new StorageError(`error from IDBKeyRange with roomId ${roomId}`, err); 38 | } 39 | } 40 | 41 | all(roomId) { 42 | return this._store.selectAll(this._allRange(roomId)); 43 | } 44 | 45 | /** Returns the fragment without a nextToken and without nextId, 46 | if any, with the largest id if there are multiple (which should not happen) */ 47 | liveFragment(roomId) { 48 | // why do we need this? 49 | // Ok, take the case where you've got a /context fragment and a /sync fragment 50 | // They are not connected. So, upon loading the persister, which one do we take? We can't sort them ... 51 | // we assume that the one without a nextToken and without a nextId is a live one 52 | // there should really be only one like this 53 | 54 | // reverse because assuming live fragment has bigger id than non-live ones 55 | return this._store.findReverse(this._allRange(roomId), fragment => { 56 | return typeof fragment.nextId !== "number" && typeof fragment.nextToken !== "string"; 57 | }); 58 | } 59 | 60 | // should generate an id an return it? 61 | // depends if we want to do anything smart with fragment ids, 62 | // like give them meaning depending on range. not for now probably ... 63 | add(fragment) { 64 | fragment.key = encodeKey(fragment.roomId, fragment.id); 65 | return this._store.add(fragment); 66 | } 67 | 68 | update(fragment) { 69 | return this._store.put(fragment); 70 | } 71 | 72 | get(roomId, fragmentId) { 73 | return this._store.get(encodeKey(roomId, fragmentId)); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /prototypes/idb2rwtxn.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /src/utils/EventEmitter.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | export class EventEmitter { 18 | constructor() { 19 | this._handlersByName = {}; 20 | } 21 | 22 | emit(name, ...values) { 23 | const handlers = this._handlersByName[name]; 24 | if (handlers) { 25 | for(const h of handlers) { 26 | h(...values); 27 | } 28 | } 29 | } 30 | 31 | disposableOn(name, callback) { 32 | this.on(name, callback); 33 | return () => { 34 | this.off(name, callback); 35 | } 36 | } 37 | 38 | on(name, callback) { 39 | let handlers = this._handlersByName[name]; 40 | if (!handlers) { 41 | this.onFirstSubscriptionAdded(name); 42 | this._handlersByName[name] = handlers = new Set(); 43 | } 44 | handlers.add(callback); 45 | } 46 | 47 | off(name, callback) { 48 | const handlers = this._handlersByName[name]; 49 | if (handlers) { 50 | handlers.delete(callback); 51 | if (handlers.length === 0) { 52 | delete this._handlersByName[name]; 53 | this.onLastSubscriptionRemoved(name); 54 | } 55 | } 56 | } 57 | 58 | onFirstSubscriptionAdded(name) {} 59 | 60 | onLastSubscriptionRemoved(name) {} 61 | } 62 | 63 | export function tests() { 64 | return { 65 | test_on_off(assert) { 66 | let counter = 0; 67 | const e = new EventEmitter(); 68 | const callback = () => counter += 1; 69 | e.on("change", callback); 70 | e.emit("change"); 71 | e.off("change", callback); 72 | e.emit("change"); 73 | assert.equal(counter, 1); 74 | }, 75 | 76 | test_emit_value(assert) { 77 | let value = 0; 78 | const e = new EventEmitter(); 79 | const callback = (v) => value = v; 80 | e.on("change", callback); 81 | e.emit("change", 5); 82 | e.off("change", callback); 83 | assert.equal(value, 5); 84 | }, 85 | 86 | test_double_on(assert) { 87 | let counter = 0; 88 | const e = new EventEmitter(); 89 | const callback = () => counter += 1; 90 | e.on("change", callback); 91 | e.on("change", callback); 92 | e.emit("change"); 93 | e.off("change", callback); 94 | assert.equal(counter, 1); 95 | } 96 | }; 97 | } 98 | -------------------------------------------------------------------------------- /prototypes/idb-cmp.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 12 | 13 | 14 | 74 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /src/ui/web/session/SessionView.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {ListView} from "../general/ListView.js"; 18 | import {RoomTile} from "./RoomTile.js"; 19 | import {RoomView} from "./room/RoomView.js"; 20 | import {SwitchView} from "../general/SwitchView.js"; 21 | import {RoomPlaceholderView} from "./RoomPlaceholderView.js"; 22 | import {SessionStatusView} from "./SessionStatusView.js"; 23 | import {tag} from "../general/html.js"; 24 | 25 | export class SessionView { 26 | constructor(viewModel) { 27 | this._viewModel = viewModel; 28 | this._middleSwitcher = null; 29 | this._roomList = null; 30 | this._currentRoom = null; 31 | this._root = null; 32 | this._onViewModelChange = this._onViewModelChange.bind(this); 33 | } 34 | 35 | root() { 36 | return this._root; 37 | } 38 | 39 | mount() { 40 | this._viewModel.on("change", this._onViewModelChange); 41 | this._sessionStatusBar = new SessionStatusView(this._viewModel.sessionStatusViewModel); 42 | this._roomList = new ListView( 43 | { 44 | className: "RoomList", 45 | list: this._viewModel.roomList, 46 | onItemClick: (roomTile, event) => roomTile.clicked(event) 47 | }, 48 | (room) => new RoomTile(room) 49 | ); 50 | this._middleSwitcher = new SwitchView(new RoomPlaceholderView()); 51 | 52 | this._root = tag.div({className: "SessionView"}, [ 53 | this._sessionStatusBar.mount(), 54 | tag.div({className: "main"}, [ 55 | tag.div({className: "LeftPanel"}, this._roomList.mount()), 56 | this._middleSwitcher.mount() 57 | ]) 58 | ]); 59 | 60 | return this._root; 61 | } 62 | 63 | unmount() { 64 | this._roomList.unmount(); 65 | this._middleSwitcher.unmount(); 66 | this._viewModel.off("change", this._onViewModelChange); 67 | } 68 | 69 | _onViewModelChange(prop) { 70 | if (prop === "currentRoom") { 71 | if (this._viewModel.currentRoom) { 72 | this._root.classList.add("room-shown"); 73 | this._middleSwitcher.switch(new RoomView(this._viewModel.currentRoom)); 74 | } else { 75 | this._root.classList.remove("room-shown"); 76 | this._middleSwitcher.switch(new RoomPlaceholderView()); 77 | } 78 | } 79 | } 80 | 81 | // changing viewModel not supported for now 82 | update() {} 83 | } 84 | -------------------------------------------------------------------------------- /src/matrix/storage/idb/Transaction.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {txnAsPromise} from "./utils.js"; 18 | import {StorageError} from "../common.js"; 19 | import {Store} from "./Store.js"; 20 | import {SessionStore} from "./stores/SessionStore.js"; 21 | import {RoomSummaryStore} from "./stores/RoomSummaryStore.js"; 22 | import {TimelineEventStore} from "./stores/TimelineEventStore.js"; 23 | import {RoomStateStore} from "./stores/RoomStateStore.js"; 24 | import {TimelineFragmentStore} from "./stores/TimelineFragmentStore.js"; 25 | import {PendingEventStore} from "./stores/PendingEventStore.js"; 26 | 27 | export class Transaction { 28 | constructor(txn, allowedStoreNames) { 29 | this._txn = txn; 30 | this._allowedStoreNames = allowedStoreNames; 31 | this._stores = { 32 | session: null, 33 | roomSummary: null, 34 | roomTimeline: null, 35 | roomState: null, 36 | }; 37 | } 38 | 39 | _idbStore(name) { 40 | if (!this._allowedStoreNames.includes(name)) { 41 | // more specific error? this is a bug, so maybe not ... 42 | throw new StorageError(`Invalid store for transaction: ${name}, only ${this._allowedStoreNames.join(", ")} are allowed.`); 43 | } 44 | return new Store(this._txn.objectStore(name)); 45 | } 46 | 47 | _store(name, mapStore) { 48 | if (!this._stores[name]) { 49 | const idbStore = this._idbStore(name); 50 | this._stores[name] = mapStore(idbStore); 51 | } 52 | return this._stores[name]; 53 | } 54 | 55 | get session() { 56 | return this._store("session", idbStore => new SessionStore(idbStore)); 57 | } 58 | 59 | get roomSummary() { 60 | return this._store("roomSummary", idbStore => new RoomSummaryStore(idbStore)); 61 | } 62 | 63 | get timelineFragments() { 64 | return this._store("timelineFragments", idbStore => new TimelineFragmentStore(idbStore)); 65 | } 66 | 67 | get timelineEvents() { 68 | return this._store("timelineEvents", idbStore => new TimelineEventStore(idbStore)); 69 | } 70 | 71 | get roomState() { 72 | return this._store("roomState", idbStore => new RoomStateStore(idbStore)); 73 | } 74 | 75 | get pendingEvents() { 76 | return this._store("pendingEvents", idbStore => new PendingEventStore(idbStore)); 77 | } 78 | 79 | complete() { 80 | return txnAsPromise(this._txn); 81 | } 82 | 83 | abort() { 84 | this._txn.abort(); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/matrix/room/timeline/Timeline.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {SortedArray, MappedList, ConcatList} from "../../../observable/index.js"; 18 | import {Direction} from "./Direction.js"; 19 | import {TimelineReader} from "./persistence/TimelineReader.js"; 20 | import {PendingEventEntry} from "./entries/PendingEventEntry.js"; 21 | 22 | export class Timeline { 23 | constructor({roomId, storage, closeCallback, fragmentIdComparer, pendingEvents, user}) { 24 | this._roomId = roomId; 25 | this._storage = storage; 26 | this._closeCallback = closeCallback; 27 | this._fragmentIdComparer = fragmentIdComparer; 28 | this._remoteEntries = new SortedArray((a, b) => a.compare(b)); 29 | this._timelineReader = new TimelineReader({ 30 | roomId: this._roomId, 31 | storage: this._storage, 32 | fragmentIdComparer: this._fragmentIdComparer 33 | }); 34 | const localEntries = new MappedList(pendingEvents, pe => { 35 | return new PendingEventEntry({pendingEvent: pe, user}); 36 | }, (pee, params) => { 37 | pee.notifyUpdate(params); 38 | }); 39 | this._allEntries = new ConcatList(this._remoteEntries, localEntries); 40 | } 41 | 42 | /** @package */ 43 | async load() { 44 | const entries = await this._timelineReader.readFromEnd(50); 45 | this._remoteEntries.setManySorted(entries); 46 | } 47 | 48 | // TODO: should we rather have generic methods for 49 | // - adding new entries 50 | // - updating existing entries (redaction, relations) 51 | /** @package */ 52 | appendLiveEntries(newEntries) { 53 | this._remoteEntries.setManySorted(newEntries); 54 | } 55 | 56 | /** @package */ 57 | addGapEntries(newEntries) { 58 | this._remoteEntries.setManySorted(newEntries); 59 | } 60 | 61 | // tries to prepend `amount` entries to the `entries` list. 62 | async loadAtTop(amount) { 63 | const firstEventEntry = this._remoteEntries.array.find(e => !!e.eventType); 64 | if (!firstEventEntry) { 65 | return; 66 | } 67 | const entries = await this._timelineReader.readFrom( 68 | firstEventEntry.asEventKey(), 69 | Direction.Backward, 70 | amount 71 | ); 72 | this._remoteEntries.setManySorted(entries); 73 | } 74 | 75 | /** @public */ 76 | get entries() { 77 | return this._allEntries; 78 | } 79 | 80 | /** @public */ 81 | close() { 82 | if (this._closeCallback) { 83 | this._closeCallback(); 84 | this._closeCallback = null; 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/domain/LoginViewModel.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {ViewModel} from "./ViewModel.js"; 18 | import {SessionLoadViewModel} from "./SessionLoadViewModel.js"; 19 | 20 | export class LoginViewModel extends ViewModel { 21 | constructor(options) { 22 | super(options); 23 | const {sessionCallback, defaultHomeServer, createSessionContainer} = options; 24 | this._createSessionContainer = createSessionContainer; 25 | this._sessionCallback = sessionCallback; 26 | this._defaultHomeServer = defaultHomeServer; 27 | this._loadViewModel = null; 28 | this._loadViewModelSubscription = null; 29 | } 30 | 31 | get defaultHomeServer() { return this._defaultHomeServer; } 32 | 33 | get loadViewModel() {return this._loadViewModel; } 34 | 35 | get isBusy() { 36 | if (!this._loadViewModel) { 37 | return false; 38 | } else { 39 | return this._loadViewModel.loading; 40 | } 41 | } 42 | 43 | async login(username, password, homeserver) { 44 | this._loadViewModelSubscription = this.disposeTracked(this._loadViewModelSubscription); 45 | if (this._loadViewModel) { 46 | this._loadViewModel.cancel(); 47 | } 48 | this._loadViewModel = new SessionLoadViewModel({ 49 | createAndStartSessionContainer: () => { 50 | const sessionContainer = this._createSessionContainer(); 51 | sessionContainer.startWithLogin(homeserver, username, password); 52 | return sessionContainer; 53 | }, 54 | sessionCallback: sessionContainer => { 55 | if (sessionContainer) { 56 | // make parent view model move away 57 | this._sessionCallback(sessionContainer); 58 | } else { 59 | // show list of session again 60 | this._loadViewModel = null; 61 | this.emitChange("loadViewModel"); 62 | } 63 | }, 64 | deleteSessionOnCancel: true, 65 | homeserver, 66 | }); 67 | this._loadViewModel.start(); 68 | this.emitChange("loadViewModel"); 69 | this._loadViewModelSubscription = this.track(this._loadViewModel.disposableOn("change", () => { 70 | if (!this._loadViewModel.loading) { 71 | this._loadViewModelSubscription = this.disposeTracked(this._loadViewModelSubscription); 72 | } 73 | this.emitChange("isBusy"); 74 | })); 75 | } 76 | 77 | cancel() { 78 | if (!this.isBusy) { 79 | this._sessionCallback(); 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/matrix/net/request/fetch.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import { 18 | AbortError, 19 | ConnectionError 20 | } from "../../error.js"; 21 | 22 | class RequestResult { 23 | constructor(promise, controller) { 24 | if (!controller) { 25 | const abortPromise = new Promise((_, reject) => { 26 | this._controller = { 27 | abort() { 28 | const err = new Error("fetch request aborted"); 29 | err.name = "AbortError"; 30 | reject(err); 31 | } 32 | }; 33 | }); 34 | this._promise = Promise.race([promise, abortPromise]); 35 | } else { 36 | this._promise = promise; 37 | this._controller = controller; 38 | } 39 | } 40 | 41 | abort() { 42 | this._controller.abort(); 43 | } 44 | 45 | response() { 46 | return this._promise; 47 | } 48 | } 49 | 50 | export function fetchRequest(url, options) { 51 | const controller = typeof AbortController === "function" ? new AbortController() : null; 52 | if (controller) { 53 | options = Object.assign(options, { 54 | signal: controller.signal 55 | }); 56 | } 57 | options = Object.assign(options, { 58 | mode: "cors", 59 | credentials: "omit", 60 | referrer: "no-referrer", 61 | cache: "no-cache", 62 | }); 63 | if (options.headers) { 64 | const headers = new Headers(); 65 | for(const [name, value] of options.headers.entries()) { 66 | headers.append(name, value); 67 | } 68 | options.headers = headers; 69 | } 70 | const promise = fetch(url, options).then(async response => { 71 | const {status} = response; 72 | const body = await response.json(); 73 | return {status, body}; 74 | }, err => { 75 | if (err.name === "AbortError") { 76 | throw new AbortError(); 77 | } else if (err instanceof TypeError) { 78 | // Network errors are reported as TypeErrors, see 79 | // https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#Checking_that_the_fetch_was_successful 80 | // this can either mean user is offline, server is offline, or a CORS error (server misconfiguration). 81 | // 82 | // One could check navigator.onLine to rule out the first 83 | // but the 2 latter ones are indistinguishable from javascript. 84 | throw new ConnectionError(`${options.method} ${url}: ${err.message}`); 85 | } 86 | throw err; 87 | }); 88 | return new RequestResult(promise, controller); 89 | } 90 | -------------------------------------------------------------------------------- /doc/CSS.md: -------------------------------------------------------------------------------- 1 | https://nio.chat/ looks nice. 2 | 3 | We could do top to bottom gradients in default avatars to make them look a bit cooler. Automatically generate them from a single color, e.g. from slightly lighter to slightly darker. 4 | 5 | ## How to organize the CSS? 6 | 7 | Can take ideas/adopt from OOCSS and SMACSS. 8 | 9 | ### Root 10 | - maybe we should not assume `body` is the root, but rather a `.brawl` class. The root is where we'd set root level css variables, fonts?, etc. Should we scope all css to this root class? That could get painful with just vanilla css. We could use something like https://github.com/domwashburn/postcss-parent-selector to only do this at build time. Other useful plugin for postcss: https://github.com/postcss/postcss-selector-parser 11 | 12 | We would still you `rem` for size units though. 13 | 14 | ### Class names 15 | 16 | #### View 17 | - view name? 18 | 19 | #### Not quite a View 20 | 21 | Some things might not be a view, as they don't have their own view model. 22 | 23 | - a spinner, has .spinner for now 24 | - avatar 25 | 26 | #### modifier classes 27 | 28 | are these modifiers? 29 | - contrast-hi, contrast-mid, contrast-low 30 | - font-large, font-medium, font-small 31 | 32 | - large, medium, small (for spinner and avatar) 33 | - hidden: hides the element, can be useful when not wanting to use an if-binding to databind on a css class 34 | - inline: can be applied to any item if it needs to look good in an inline layout 35 | - flex: can be applied to any item if it is placed in a flex container. You'd combine this with some other class to set a `flex` that makes sense, e.g.: 36 | ```css 37 | .spinner.flex, 38 | .avatar.flex, 39 | .icon.flex, 40 | button.flex { 41 | flex: 0; 42 | } 43 | ``` 44 | you could end up with a lot of these though? 45 | 46 | well... for flex we don't really need a class, as `flex` doesn't do anything if the parent is not a flex container. 47 | 48 | Modifier classes can be useful though. Should we prefix them? 49 | 50 | ### Theming 51 | 52 | do we want as system with HSL or RGBA to define shades and contrasts? 53 | 54 | we could define colors as HS and have a separate value for L: 55 | 56 | ``` 57 | /* for dark theme */ 58 | --lightness-mod: -1; 59 | --accent-shade: 310, 70%; 60 | /* then at every level */ 61 | --lightness: 60%; 62 | /* add/remove (based on dark theme) 20% lightness */ 63 | --lightness: calc(var(--lightness) + calc(var(--lightness-mod) * 20%)); 64 | --bg-color: hsl(var(-accent-shade), var(--lightness)); 65 | ``` 66 | 67 | this makes it easy to derive colors, but if there is no override with rga values, could be limiting. 68 | I guess --fg-color and --bg-color can be those overrides? 69 | 70 | what theme color variables do we want? 71 | 72 | - accent color 73 | - avatar/name colors 74 | - background color (panels are shades of this?) 75 | 76 | Themes are specified as JSON and need javascript to be set. The JSON contains colors in rgb, the theme code will generate css variables containing shades as specified? Well, that could be custom theming, but built-in themes should have full css flexibility. 77 | 78 | what hierarchical variables do we want? 79 | 80 | - `--fg-color` (we use this instead of color so icons and borders can also take the color, we could use the `currentcolor` constant for this though!) 81 | - `--bg-color` (we use this instead of background so icons and borders can also take the color) 82 | - `--lightness` 83 | - `--size` for things like spinner, avatar 84 | -------------------------------------------------------------------------------- /src/ui/web/session/room/TimelineList.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Bruno Windels 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import {ListView} from "../../general/ListView.js"; 18 | import {GapView} from "./timeline/GapView.js"; 19 | import {TextMessageView} from "./timeline/TextMessageView.js"; 20 | import {ImageView} from "./timeline/ImageView.js"; 21 | import {AnnouncementView} from "./timeline/AnnouncementView.js"; 22 | 23 | export class TimelineList extends ListView { 24 | constructor(options = {}) { 25 | options.className = "Timeline"; 26 | super(options, entry => { 27 | switch (entry.shape) { 28 | case "gap": return new GapView(entry); 29 | case "announcement": return new AnnouncementView(entry); 30 | case "message": return new TextMessageView(entry); 31 | case "image": return new ImageView(entry); 32 | } 33 | }); 34 | this._atBottom = false; 35 | this._onScroll = this._onScroll.bind(this); 36 | this._topLoadingPromise = null; 37 | this._viewModel = null; 38 | } 39 | 40 | async _onScroll() { 41 | const root = this.root(); 42 | if (root.scrollTop === 0 && !this._topLoadingPromise && this._viewModel) { 43 | const beforeFromBottom = this._distanceFromBottom(); 44 | this._topLoadingPromise = this._viewModel.loadAtTop(); 45 | await this._topLoadingPromise; 46 | const fromBottom = this._distanceFromBottom(); 47 | const amountGrown = fromBottom - beforeFromBottom; 48 | root.scrollTop = root.scrollTop + amountGrown; 49 | this._topLoadingPromise = null; 50 | } 51 | } 52 | 53 | update(attributes) { 54 | if(attributes.viewModel) { 55 | this._viewModel = attributes.viewModel; 56 | attributes.list = attributes.viewModel.tiles; 57 | } 58 | super.update(attributes); 59 | } 60 | 61 | mount() { 62 | const root = super.mount(); 63 | root.addEventListener("scroll", this._onScroll); 64 | return root; 65 | } 66 | 67 | unmount() { 68 | this.root().removeEventListener("scroll", this._onScroll); 69 | super.unmount(); 70 | } 71 | 72 | loadList() { 73 | super.loadList(); 74 | const root = this.root(); 75 | root.scrollTop = root.scrollHeight; 76 | } 77 | 78 | onBeforeListChanged() { 79 | const fromBottom = this._distanceFromBottom(); 80 | this._atBottom = fromBottom < 1; 81 | } 82 | 83 | _distanceFromBottom() { 84 | const root = this.root(); 85 | return root.scrollHeight - root.scrollTop - root.clientHeight; 86 | } 87 | 88 | onListChanged() { 89 | if (this._atBottom) { 90 | const root = this.root(); 91 | root.scrollTop = root.scrollHeight; 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /prototypes/chrome-keys2.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 101 | 102 | -------------------------------------------------------------------------------- /prototypes/chrome-keys3.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 102 | 103 | --------------------------------------------------------------------------------