├── .buildkite └── pipeline.yml ├── .eslintrc.yml ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── babel.config.js ├── config.sample.json ├── docs └── slide-theory.md ├── package.json ├── public └── index.html ├── src ├── App.vue ├── assets │ └── logo.png ├── components │ ├── ActionButton.vue │ ├── Editor │ │ └── SlideRoomEditor.vue │ ├── Forms │ │ └── InputGroup.vue │ ├── Login.vue │ ├── Modal.vue │ ├── Nav.vue │ ├── QRCode.vue │ ├── SettingsModal.vue │ ├── SettingsModalTabs │ │ ├── GeneralTab.vue │ │ └── KeymappingTab.vue │ ├── Slide.vue │ ├── SlideCard.vue │ ├── SlideFragment.vue │ ├── SlideList.vue │ ├── SlideRoom.vue │ ├── Slides │ │ ├── ReactionButton.vue │ │ ├── ReactionViewer.vue │ │ └── SlideTools.vue │ ├── SubscribeModal.vue │ ├── Sync.vue │ └── TableTennis.vue ├── main.scss ├── main.ts ├── matrix-js-sdk.d.ts ├── models │ ├── PositionEvent.ts │ └── SlidesEvent.ts ├── pages │ ├── CreateSlideshow.vue │ ├── Home.vue │ ├── Login.vue │ └── Slides.vue ├── router │ └── index.ts ├── shims-tsx.d.ts ├── shims-vue.d.ts ├── util │ ├── eventStore.ts │ ├── matrix.ts │ └── store.ts └── vue-feather-icons.d.ts ├── tsconfig.json ├── tslint.json ├── vue.config.js └── yarn.lock /.buildkite/pipeline.yml: -------------------------------------------------------------------------------- 1 | steps: 2 | - label: ':hammer: Build' 3 | command: 4 | - "yarn install" 5 | # Fix permission issues 6 | - "chmod -R a+rwx node_modules" 7 | - "yarn build" 8 | plugins: 9 | - docker#v3.5.0: 10 | image: "node:12" 11 | 12 | - label: ':eslint: Linting' 13 | command: 14 | - "yarn lint" 15 | plugins: 16 | - docker#v3.5.0: 17 | image: "node:12" 18 | 19 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | 2 | root: true 3 | 4 | parser: 'vue-eslint-parser' 5 | 6 | env: 7 | es6: true 8 | node: true 9 | 10 | extends: 11 | - plugin:vue/essential 12 | - plugin:@typescript-eslint/eslint-recommended 13 | - plugin:@typescript-eslint/recommended 14 | 15 | parserOptions: 16 | parser: '@typescript-eslint/parser' 17 | sourceType: module 18 | extraFileExtensions: ['.vue', '.ts'] 19 | ecmaFeatures: 20 | jsx: true 21 | 22 | plugins: 23 | # Local version of @typescript-eslint/eslint-plugin 24 | - '@typescript-eslint' 25 | 26 | rules: 27 | '@typescript-eslint/no-explicit-any': 'error' 28 | '@typescript-eslint/member-delimiter-style': 'off' 29 | '@typescript-eslint/explicit-function-return-type': 'off' 30 | '@typescript-eslint/camelcase': 31 | - "error" 32 | - properties: "never" 33 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - name: 🖥️ Setup Node 11 | uses: actions/setup-node@v1 12 | with: 13 | node-version: 12.x 14 | - name: 🧶 Install 15 | run: yarn install 16 | - name: Copy config 17 | run: cp config.sample.json config.json 18 | - name: 🔨 Build 19 | run: yarn build 20 | lint: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v2 24 | - name: 🖥️ Setup Node 25 | uses: actions/setup-node@v1 26 | with: 27 | node-version: 12.x 28 | - name: 🧶 Install 29 | run: yarn install 30 | - name: 📇 Lint 31 | run: yarn lint 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | /dist 64 | 65 | config.json -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Will Hunt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # matrix-presents 2 | 3 | A presentation client that reads from a Matrix room and displays it as pretty slides! 4 | 5 | For help and support, visit [#presents:half-shot.uk](https://matrix.to/#/#presents:half-shot.uk) 6 | 7 | ## Project setup 8 | 9 | To run the project: 10 | 11 | ``` 12 | git clone https://github.com/Half-Shot/matrix-presents 13 | cd matrix-presents 14 | yarn 15 | ``` 16 | Create `config.json` (e.g. based on `config.sample.json`) then 17 | ``` 18 | yarn serve 19 | ``` 20 | 21 | ## Docs 22 | 23 | - [Slide Theory](docs/slide-theory.md) contains some information about how Presents uses Matrix events and rooms. 24 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ], 5 | plugins: [ 6 | '@babel/plugin-proposal-optional-chaining', 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /config.sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "guest_homeserver": "https://reckless.half-shot.uk", 3 | "base_uri": "https://presents.half-shot.uk" 4 | } -------------------------------------------------------------------------------- /docs/slide-theory.md: -------------------------------------------------------------------------------- 1 | Slide Theory 2 | ------------ 3 | 4 | This document describes how `matrix-presents` uses rooms and events to store slides. 5 | 6 | ## The Room 7 | 8 | One presentation is stored inside one room. 9 | 10 | A room will have a `uk.half-shot.presents.slides` state event with an empty `state_key`. 11 | The event will contain a number of event_ids which correspond to each slide. 12 | 13 | ``` 14 | { 15 | "slides": [ 16 | "$jBKbAKRMgJ-SO37RfAOM6UtCwibPzn8zzMPxv85928k", 17 | "$j9jua8Rsd7TV4mkhvBA8jQIz0OLQoX75pd6vJqn1AO4", 18 | "$bgYjfoZsCjG9e3-OlRUWeluiLdCtLnvUt-Uis4l8Jms" 19 | ] 20 | } 21 | ``` 22 | *Example event content for `uk.half-shot.presents.slides`* 23 | 24 | This allows a client to index all the slides, as well as store the ordering of the presentation. 25 | Currently the ordering of slides is linear, where the index of the event_id denotes the ordering of 26 | the slides. 27 | 28 | Any `uk.half-shot.presents.slide` event can be added into this index, from any `sender`. 29 | 30 | The powerlevel required to send `uk.half-shot.presents.slides` decides who is considered an **Editor** in 31 | a room. In Matrix, you require only PL50 to send a state event into the room, so by default all 32 | "Moderators" in a room are also Editors. Clients should ensure that the PL required to send this event is 33 | sufficently high for the user's needs. 34 | 35 | ## Slides 36 | 37 | Slides are stored inside the same room, and can be created by anyone. They should be normal events. 38 | 39 | ### Fragment Events 40 | 41 | Fragment events are ANY event that can be rendered inside a slide. Clients may decide for themselves which 42 | events they wish to render in a slide (and show an appropriate fallback if not), but allclients should 43 | display: 44 | 45 | - `m.room.message` 46 | - `msgtype`: 47 | - `m.text` (both HTML and plain) 48 | - `m.image` 49 | 50 | Anybody may create fragment, and again care should be taken to ensure that the PLs for the room have been 51 | set correctly. Anyone able to send any event is considered a **Contributor**. 52 | 53 | ### Slide Events 54 | 55 | Slide events will contain references to the child events. 56 | 57 | An example `uk.half-shot.presents.slide` event: 58 | ```json 59 | { 60 | "title": "Foobar", 61 | "subtitle": "The need for foo", 62 | "columns": [ 63 | ["!foobar:example.com"], 64 | ["!bar:baz.com"] 65 | ] 66 | } 67 | ``` 68 | *Example event content for `uk.half-shot.presents.slide`* 69 | 70 | A slide may contain a `title` and a `subtitle`. These are plaintext that may be rendered 71 | inside a heading. If `columns` is not defined or is empty (but NOT if it contains an empty 72 | array), then the title and subtitle should be rendered as a "title card". That is, they should be rendered 73 | centered. Optionally, if the event is also the first slide, the author of the slide may be present. 74 | Otherwise, the `title` and `subtitle` are optional and may be rendered above the rest of the slide. 75 | 76 | `columns` defines the fragments to be rendered inside a slide, seperated into columns in the show. A 77 | simple slide may only have one column with one item (and therefore should be rendered centered). Two or 78 | more columns should be equally spaced out along the slide. Columns may not yet have sub-columns, due to 79 | the difficulties in rendering subcolumns without impairing readability. Fragments should be rendered on 80 | top of each other per column, and width/height should be dynamically adjusted depending on the type of 81 | fragment. 82 | 83 | Fragments can be lazyloaded into a slide, or a client may wait for all the fragments to be loaded before 84 | displaying the slide. Smarter clients may attempt to "buffer" the next few slides while rendering the 85 | current one to avoid any slowdowns during a presentation. 86 | 87 | As with the parent `uk.half-shot.presents.slides` event, PLs should be chosen with care. By default, 88 | anyone can send a slide event into a room although only slides inside the parent will be rendered to \ 89 | viewers. 90 | 91 | ### Edits 92 | 93 | Edits are currently an anomoly in that the previous event cannot point to the edited event, so it is hard to notify clients of an edit unless they see it come down sync. For the time being, editing a fragment will require editing the parent slide event. 94 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "matrix-presents", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "dev": "vue-cli-service serve --mode development", 8 | "build": "vue-cli-service build", 9 | "lint": "eslint src/**/*.vue src/**/*.ts" 10 | }, 11 | "dependencies": { 12 | "animate.css": "^3.7.2", 13 | "core-js": "^3.4.4", 14 | "highlight.js": "^9.17.1", 15 | "matrix-js-sdk": "^3.0.0", 16 | "qrcode": "^1.4.4", 17 | "twemoji": "^12.1.5", 18 | "v-emoji-picker": "^2.0.3", 19 | "vue": "^2.6.10", 20 | "vue-class-component": "^7.0.2", 21 | "vue-feather-icons": "^5.0.0", 22 | "vue-property-decorator": "^8.3.0", 23 | "vue-router": "^3.1.3" 24 | }, 25 | "devDependencies": { 26 | "@babel/plugin-proposal-optional-chaining": "^7.8.3", 27 | "@types/highlight.js": "^9.12.3", 28 | "@types/qrcode": "^1.3.4", 29 | "@types/twemoji": "^12.1.0", 30 | "@types/vue-router": "^2.0.0", 31 | "@typescript-eslint/eslint-plugin": "^2.16.0", 32 | "@typescript-eslint/parser": "^2.16.0", 33 | "@vue/cli-plugin-babel": "^4.1.0", 34 | "@vue/cli-plugin-router": "^4.1.0", 35 | "@vue/cli-plugin-typescript": "^4.1.0", 36 | "@vue/cli-service": "^4.1.0", 37 | "eslint": "^6.8.0", 38 | "eslint-plugin-vue": "^6.1.2", 39 | "sass": "^1.23.7", 40 | "sass-loader": "^8.0.0", 41 | "typescript": "~3.7.5", 42 | "vue-eslint-parser": "^7.0.0", 43 | "vue-template-compiler": "^2.6.10" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | matrix-presents 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 19 | 20 | 58 | 59 | 108 | -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Half-Shot/matrix-presents/7643fc47b7eca0db5e265513c2ecb4ceb58cda78/src/assets/logo.png -------------------------------------------------------------------------------- /src/components/ActionButton.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 28 | 29 | -------------------------------------------------------------------------------- /src/components/Editor/SlideRoomEditor.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | -------------------------------------------------------------------------------- /src/components/Forms/InputGroup.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 22 | 23 | -------------------------------------------------------------------------------- /src/components/Login.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 42 | 43 | 94 | 95 | -------------------------------------------------------------------------------- /src/components/Modal.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 62 | 63 | -------------------------------------------------------------------------------- /src/components/Nav.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 53 | 54 | -------------------------------------------------------------------------------- /src/components/QRCode.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | -------------------------------------------------------------------------------- /src/components/SettingsModal.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 50 | 51 | -------------------------------------------------------------------------------- /src/components/SettingsModalTabs/GeneralTab.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /src/components/SettingsModalTabs/KeymappingTab.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /src/components/Slide.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 101 | 102 | -------------------------------------------------------------------------------- /src/components/SlideCard.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 43 | 44 | -------------------------------------------------------------------------------- /src/components/SlideFragment.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 50 | 51 | -------------------------------------------------------------------------------- /src/components/SlideList.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 64 | 65 | 73 | -------------------------------------------------------------------------------- /src/components/SlideRoom.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 60 | 61 | 292 | 293 | 294 | 322 | -------------------------------------------------------------------------------- /src/components/Slides/ReactionButton.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 24 | 25 | -------------------------------------------------------------------------------- /src/components/Slides/ReactionViewer.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 46 | 47 | -------------------------------------------------------------------------------- /src/components/Slides/SlideTools.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 16 | 17 | -------------------------------------------------------------------------------- /src/components/SubscribeModal.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 32 | 33 | // This is not scoped, because we want to modify inner. 34 | -------------------------------------------------------------------------------- /src/components/Sync.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 13 | -------------------------------------------------------------------------------- /src/components/TableTennis.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 30 | 31 | -------------------------------------------------------------------------------- /src/main.scss: -------------------------------------------------------------------------------- 1 | // SASS style sheet */ 2 | // Palette color codes */ 3 | // Palette URL: http://paletton.com/#uid=50D0q1kv-rAmAI2rDzPwqkzEYb+k1Wvra0Dl5iAC54oJ7Jm0kjoqakdSxkc-ahugkfY6R */ 4 | 5 | // As RGBa codes */ 6 | 7 | $color-primary-0: rgba(220,119, 3,1); // Main Primary color */ 8 | $color-primary-1: rgba(255,171, 75,1); 9 | $color-primary-2: rgba(255,153, 35,1); 10 | $color-primary-3: rgba(164, 88, 0,1); 11 | $color-primary-4: rgba( 95, 51, 0,1); 12 | 13 | $color-secondary-1-0: rgba(250,240,235,1); // Main Secondary color (1) */ 14 | $color-secondary-1-1: rgba(255,197,175,1); 15 | $color-secondary-1-2: rgba(255,224,213,1); 16 | $color-secondary-1-3: rgba(197,174,166,1); 17 | $color-secondary-1-4: rgba(175,145,133,1); 18 | 19 | $color-secondary-2-0: rgba(209,170, 82,1); // Main Secondary color (2) */ 20 | $color-secondary-2-1: rgba(255,206, 94,1); 21 | $color-secondary-2-2: rgba(255,206, 94,1); 22 | $color-secondary-2-3: rgba(130,108, 59,1); 23 | $color-secondary-2-4: rgba( 55, 46, 27,1); 24 | 25 | // Generated by Paletton.com © 2002-2014 */ 26 | // http://paletton.com */ 27 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import App from "./App.vue"; 3 | import router from "./router"; 4 | import store from "./util/store"; 5 | import twemoji from "twemoji"; 6 | 7 | Vue.config.productionTip = false; 8 | 9 | new Vue({ 10 | router, 11 | data: { 12 | privateState: {}, 13 | sharedState: store, 14 | }, 15 | render: (h) => h(App), 16 | }).$mount("#app"); 17 | 18 | Vue.directive('emoji', { 19 | inserted (el) { 20 | el.innerHTML = twemoji.parse(el.innerHTML) 21 | } 22 | }) 23 | -------------------------------------------------------------------------------- /src/matrix-js-sdk.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | declare module "matrix-js-sdk" { 3 | 4 | export function createClient(opts: { 5 | accessToken?: string, 6 | baseUrl: string, 7 | userId?: string, 8 | deviceId?: string, 9 | store?: IndexedDBStore, 10 | unstableClientRelationAggregation?: boolean, 11 | timelineSupport?: boolean, 12 | }): MatrixClient; 13 | 14 | export interface LoginData { 15 | identifier: { 16 | type: "m.id.user"; 17 | user: string; 18 | }; 19 | password: string; 20 | initial_device_display_name?: string; 21 | } 22 | 23 | export class MatrixEvent { 24 | event: any; 25 | constructor(evData: unknown); 26 | getId(): string; 27 | getSender(): RoomMember; 28 | getType(): string; 29 | getRoomId(): string; 30 | getTs(): number; 31 | getContent(): any; 32 | getStateKey(): string|undefined; 33 | isState(): boolean; 34 | } 35 | 36 | export class MatrixClient { 37 | public store: { 38 | deleteAllData(): Promise; 39 | }; 40 | public login(loginType: string, data: LoginData): Promise<{user_id: string, access_token: string, device_id: string}>; 41 | public logout(): Promise; 42 | public getProfileInfo(userId: string): Promise<{displayname: string|null, avatar_url: string|null}|null>; 43 | public getOrCreateFilter(filterName: string, filter: Filter): Promise; 44 | public stopClient(): void; 45 | public getRooms(): Room[]; 46 | public getRoom(roomId: string): Room; 47 | public joinRoom(roomIdOrAlias: string): Promise<{room_id: string}>; 48 | public getRoomIdForAlias(alias: string): Promise<{room_id: string}>; 49 | public on(event: string, listener: (...params: []) => any): MatrixClient; 50 | public on(event: "sync", listener: (state: string, prevState: string, data: any) => void): MatrixClient; 51 | public on(event: "event", listener: (event: MatrixEvent) => void): MatrixClient; 52 | public on(event: "Room.name", listener: (room: Room) => void): MatrixClient; 53 | public once(event: "Room", listener: (room: Room) => void): MatrixClient; 54 | public once(event: string, listener: (...params: []) => any): MatrixClient; 55 | public removeListener(event: string, listener: any): MatrixClient; 56 | public getSyncState(): string|null; 57 | public getHomeserverUrl(): string; 58 | public mxcUrlToHttp(url: string): string; 59 | public sendEvent(roomId: string, eventType: string, content: any): Promise; 60 | public sendStateEvent(roomId: string, eventType: string, content: any, stateKey: string|""): Promise; 61 | public getUserId(): string; 62 | public fetchRoomEvent(roomId: string, eventId: string): Promise; 63 | public registerGuest(): Promise; 64 | public startClient(): void; 65 | public setGuest(isGuest: boolean): void; 66 | public createRoom(options: { 67 | room_alias_name?: string, 68 | visibility?: "public"|"private", 69 | invite?: string[], 70 | name?: string, 71 | topic?: string, 72 | }): Promise<{room_id: string}>; 73 | public getEventTimeline(): any; 74 | } 75 | export class AutoDiscovery { 76 | public static findClientConfig(domain: string): Promise; 77 | } 78 | 79 | export class DiscoveredClientConfig { 80 | public "m.homeserver": { 81 | state: AutoDiscoveryState, 82 | error: AutoDiscoveryError|false, 83 | url: string, 84 | }; 85 | public "m.identity_server": { 86 | state: AutoDiscoveryState, 87 | url: string, 88 | }; 89 | constructor(); 90 | } 91 | 92 | export enum AutoDiscoveryState { 93 | /** 94 | * The auto discovery failed. The client is expected to communicate 95 | * the error to the user and refuse logging in. 96 | */ 97 | FAIL_ERROR = "FAIL_ERROR", 98 | 99 | /** 100 | * The auto discovery failed, however the client may still recover 101 | * from the problem. The client is recommended to that the same 102 | * action it would for PROMPT while also warning the user about 103 | * what went wrong. The client may also treat this the same as 104 | * a FAIL_ERROR state. 105 | */ 106 | FAIL_PROMPT = "FAIL_PROMPT", 107 | /** 108 | * The auto discovery didn't fail but did not find anything of 109 | * interest. The client is expected to prompt the user for more 110 | * information, or fail if it prefers. 111 | */ 112 | PROMPT = "PROMPT", 113 | /** 114 | * The auto discovery was successful. 115 | */ 116 | SUCCESS = "SUCCESS", 117 | } 118 | 119 | export enum AutoDiscoveryError { 120 | ERROR_INVALID = "Invalid homeserver discovery response", 121 | ERROR_GENERIC_FAILURE = "Failed to get autodiscovery configuration from server", 122 | ERROR_INVALID_HS_BASE_URL = "Invalid base_url for m.homeserver", 123 | ERROR_INVALID_HOMESERVER = "Homeserver URL does not appear to be a valid Matrix homeserver", 124 | ERROR_INVALID_IS_BASE_URL = "Invalid base_url for m.identity_server", 125 | ERROR_INVALID_IDENTITY_SERVER = "Identity server URL does not appear to be a valid identity server", 126 | ERROR_INVALID_IS = "Invalid identity server discovery response", 127 | ERROR_MISSING_WELLKNOWN = "No .well-known JSON file found", 128 | ERROR_INVALID_JSON = "Invalid JSON", 129 | } 130 | 131 | export class Filter { 132 | constructor(userId: string, filterId: string); 133 | public setDefinition(def: any): void; 134 | } 135 | 136 | export class IndexedDBStore { 137 | constructor(opts: { 138 | indexedDB: IDBFactory 139 | }); 140 | startup(): Promise; 141 | } 142 | 143 | export class RoomMember { 144 | public readonly rawDisplayName: string; 145 | public getAvatarUrl(baseUrl: string, width: number, height: number, resizeMethod: "crop"|"scale"): string; 146 | } 147 | 148 | export class RoomState { 149 | maySendEvent(eventType: string, userId: string): boolean; 150 | maySendStateEvent(eventType: string, userId: string): boolean; 151 | getStateEvents(eventType: string, stateKey: string): MatrixEvent | MatrixEvent[]; 152 | } 153 | 154 | export class Room { 155 | public readonly roomId: string; 156 | public readonly name: string; 157 | public readonly _client: MatrixClient; 158 | public readonly myUserId: string; 159 | public readonly currentState: RoomState; 160 | public findEventById(eventId: string): string; 161 | public getLiveTimeline(): any; 162 | public getMember(userId: string): RoomMember; 163 | public getMembersWithMembership(membership: string): RoomMember[]; 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/models/PositionEvent.ts: -------------------------------------------------------------------------------- 1 | export const PositionEventType = "uk.half-shot.presents.position"; 2 | 3 | export interface PositionEvent { 4 | type: "uk.half-shot.presents.position"; 5 | getContent: () => { 6 | event_id: string, 7 | }; 8 | } 9 | -------------------------------------------------------------------------------- /src/models/SlidesEvent.ts: -------------------------------------------------------------------------------- 1 | export const SlidesEventType = "uk.half-shot.presents.slides"; 2 | 3 | export interface SlidesEvent { 4 | type: "uk.half-shot.presents.slides"; 5 | getContent: () => { 6 | slides: [], 7 | }; 8 | } 9 | -------------------------------------------------------------------------------- /src/pages/CreateSlideshow.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 43 | 44 | -------------------------------------------------------------------------------- /src/pages/Home.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 61 | 62 | 92 | -------------------------------------------------------------------------------- /src/pages/Login.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | -------------------------------------------------------------------------------- /src/pages/Slides.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 130 | -------------------------------------------------------------------------------- /src/router/index.ts: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import VueRouter, { RouteConfig } from "vue-router"; 3 | import Home from "../pages/Home.vue"; 4 | import store from "../util/store"; 5 | import { logoutClient } from "../util/matrix"; 6 | Vue.use(VueRouter); 7 | 8 | const routes: RouteConfig[] = [ 9 | { 10 | path: "/", 11 | name: "home", 12 | component: Home, 13 | beforeEnter: (to, from, next) => { 14 | if (!store.isGuest) { 15 | next(); 16 | return; 17 | } 18 | console.log("Not logged in, redirecting to /login"); 19 | next("/login"); 20 | return; 21 | }, 22 | }, 23 | { 24 | path: "/login", 25 | name: "login", 26 | beforeEnter: (to, from, next) => { 27 | if (store.isGuest) { 28 | next(); 29 | return; 30 | } 31 | console.log("Already logged in, redirecting to /home"); 32 | next("/"); 33 | return; 34 | }, 35 | component: () => import("../pages/Login.vue"), 36 | }, 37 | { 38 | path: "/logout", 39 | name: "logout", 40 | beforeEnter: async (to, from, next) => { 41 | console.log("Vaping login credentials"); 42 | await logoutClient(); 43 | next("/login"); 44 | }, 45 | }, 46 | { 47 | path: "/slides-create", 48 | name: "slides-create", 49 | component: () => import("../pages/CreateSlideshow.vue"), 50 | }, 51 | { 52 | path: "/slides/:roomId/:eventId?", 53 | name: "slides", 54 | props: { 55 | editor: false, 56 | }, 57 | component: () => import("../pages/Slides.vue"), 58 | }, 59 | { 60 | path: "/editor/:roomId/:eventId?", 61 | name: "slides-editor", 62 | props: { 63 | editor: true, 64 | }, 65 | component: () => import("../pages/Slides.vue"), 66 | }, 67 | ]; 68 | 69 | const router = new VueRouter({ 70 | mode: "history", 71 | base: process.env.BASE_URL, 72 | routes, 73 | }); 74 | 75 | export default router; 76 | -------------------------------------------------------------------------------- /src/shims-tsx.d.ts: -------------------------------------------------------------------------------- 1 | import Vue, { VNode } from "vue"; 2 | 3 | declare global { 4 | namespace JSX { 5 | // tslint:disable no-empty-interface 6 | interface Element extends VNode {} 7 | // tslint:disable no-empty-interface 8 | interface ElementClass extends Vue {} 9 | interface IntrinsicElements { 10 | [elem: string]: any; 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.vue" { 2 | import Vue from "vue"; 3 | export default Vue; 4 | } 5 | -------------------------------------------------------------------------------- /src/util/eventStore.ts: -------------------------------------------------------------------------------- 1 | import { getClient } from "./matrix"; 2 | import { MatrixEvent } from "matrix-js-sdk"; 3 | 4 | const DB_NAME = "presents"; 5 | const DB_VERSION = 1; 6 | let database: IDBDatabase|null = null; 7 | 8 | export async function initiate() { 9 | const res = indexedDB.open(DB_NAME, DB_VERSION); 10 | res.onupgradeneeded = ((ev) => { 11 | const db = (ev.target as IDBOpenDBRequest).result; 12 | const os = db.createObjectStore("event-cache", { keyPath: "eventId"}); 13 | os.createIndex("eventId", "eventId", { unique: true }); 14 | os.createIndex("roomId", "roomId", { unique: false }); 15 | }); 16 | 17 | res.onsuccess = ((ev) => { 18 | console.log("DB opened:", ev); 19 | database = res.result; 20 | }); 21 | 22 | res.onerror = ((ev) => { 23 | console.log("DB error:", ev); 24 | }); 25 | } 26 | 27 | export async function getMatrixEvent(roomId: string, eventId: string) { 28 | console.debug(`Fetching ${roomId} ${eventId}`); 29 | const getPromise = new Promise((resolve, reject) => { 30 | if (!database) { 31 | throw Error("Database not initiated"); 32 | } 33 | const txn = database.transaction("event-cache", "readonly"); 34 | const store = txn.objectStore("event-cache"); 35 | const dataReq = store.get(eventId); 36 | dataReq.onsuccess = function(unusedEv) { resolve(dataReq.result?.data); }; 37 | dataReq.onerror = function(unusedEv) { reject(dataReq.error); }; 38 | }); 39 | 40 | try { 41 | const getResult = await getPromise; 42 | if (getResult) { 43 | return new MatrixEvent(getResult); 44 | } 45 | } catch (ex) { 46 | console.warn("Error couldn't get cached ev:", ex); 47 | } 48 | 49 | const client = getClient(); 50 | const ev = new MatrixEvent(await client.fetchRoomEvent( 51 | roomId, 52 | eventId, 53 | )); 54 | console.debug(`Got non-cached event ${roomId}:${eventId}`); 55 | if (database) { 56 | await new Promise((resolve, reject) => { 57 | if (!database) { 58 | throw Error("Database not initiated"); 59 | } 60 | const txn = database.transaction("event-cache", "readwrite"); 61 | const store = txn.objectStore("event-cache"); 62 | const dataReq = store.put({ 63 | eventId, 64 | roomId, 65 | data: ev.event, 66 | }); 67 | dataReq.onsuccess = function(unusedEv) { resolve(dataReq.result); }; 68 | dataReq.onerror = function(unusedEv) { reject(dataReq.error); }; 69 | }); 70 | } 71 | 72 | return ev; 73 | } -------------------------------------------------------------------------------- /src/util/matrix.ts: -------------------------------------------------------------------------------- 1 | import { AutoDiscovery, createClient, MatrixClient, IndexedDBStore, MatrixEvent } from "matrix-js-sdk"; 2 | import Store from "./store"; 3 | 4 | let matrixClient: MatrixClient|undefined; 5 | 6 | export async function discoverHomeserver(domain: string) { 7 | return (await AutoDiscovery.findClientConfig(domain))["m.homeserver"]; 8 | } 9 | 10 | export async function loginToMatrix(homeserver: string, username: string, password: string) { 11 | return createClient({ baseUrl: homeserver }).login("m.login.password", { 12 | identifier: { 13 | type: "m.id.user", 14 | user: username, 15 | }, 16 | password, 17 | }); 18 | } 19 | 20 | export async function registerGuestIfNotLoggedIn(suggestedHs: string|null) { 21 | // Create a guest 22 | if (Store.accessToken) { 23 | return; 24 | } 25 | if (suggestedHs && !suggestedHs.startsWith("http")) { 26 | suggestedHs = `https://${suggestedHs}`; 27 | } 28 | const baseUrl = suggestedHs || Store.homeserver || Store.defaultHomeserver; 29 | console.log(`Creating a guest account on ${baseUrl}`); 30 | const res = await createClient({ 31 | baseUrl, 32 | }).registerGuest(); 33 | Store.accessToken = res.access_token; 34 | Store.userId = res.user_id; 35 | Store.homeserver = baseUrl; 36 | Store.isGuest = true; 37 | } 38 | 39 | export function createGlobalClient() { 40 | if (!Store.homeserver) { 41 | throw Error("Tried to createGlobalClient, but Store.homeserver is not set"); 42 | } 43 | const store = new IndexedDBStore({ 44 | indexedDB: window.indexedDB, 45 | }); 46 | store.startup(); 47 | matrixClient = createClient({ 48 | baseUrl: Store.homeserver, 49 | accessToken: Store.accessToken, 50 | userId: Store.userId || undefined, 51 | deviceId: Store.deviceId, 52 | store, 53 | unstableClientRelationAggregation: true, 54 | timelineSupport: true, 55 | }); 56 | 57 | // XXX: Super naughty aggregations api. 58 | matrixClient._mpAggregations = async function (roomId: string, eventId: string) { 59 | return this._http.authedRequest( 60 | undefined, 61 | "GET", 62 | `/rooms/${encodeURIComponent(roomId)}/aggregations/${encodeURIComponent(eventId)}/m.annotation/m.reaction`, 63 | undefined, 64 | undefined, 65 | { prefix: "/_matrix/client/unstable" }, 66 | ); 67 | }; 68 | matrixClient._mpRelation = async function (roomId: string, eventId: string, emoji: string) { 69 | return this._http.authedRequest( 70 | undefined, 71 | "POST", 72 | `/rooms/${encodeURIComponent(roomId)}/send_relation/${encodeURIComponent(eventId)}/m.annotation/m.reaction?key=${emoji}`, 73 | undefined, 74 | {}, 75 | ); 76 | }; 77 | 78 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 79 | (window as any).mxClient = matrixClient; 80 | 81 | matrixClient.setGuest(Store.isGuest); 82 | return matrixClient; 83 | } 84 | 85 | export function getClient() { 86 | return matrixClient ? matrixClient : createGlobalClient(); 87 | } 88 | 89 | export async function logoutClient() { 90 | try { 91 | Store.vapeLogin(); 92 | const existingClient = getClient(); 93 | if (existingClient) { 94 | existingClient.stopClient(); 95 | await existingClient.store.deleteAllData(); 96 | existingClient.logout().catch((ex) => { 97 | console.log("Could not logout:", ex); 98 | }); 99 | console.log("Destroyed existing client"); 100 | } 101 | } catch (ex) { 102 | console.log("Failed to /logout", ex); 103 | } 104 | matrixClient = undefined; 105 | } -------------------------------------------------------------------------------- /src/util/store.ts: -------------------------------------------------------------------------------- 1 | import config from "../../config.json"; 2 | 3 | interface State { 4 | userId: string|null; 5 | displayName?: string; 6 | accessToken?: string; 7 | homeserver?: string; 8 | deviceId?: string; 9 | pageName: string|null; 10 | isGuest: boolean; 11 | } 12 | 13 | class Store { 14 | 15 | public state: State = { 16 | userId: null, 17 | pageName: null, 18 | isGuest: false, 19 | }; 20 | 21 | constructor() { 22 | // Read from storage 23 | this.displayName = sessionStorage.getItem("matrix-presents.displayname") || undefined; 24 | this.userId = localStorage.getItem("matrix-presents.user_id"); 25 | this.homeserver = localStorage.getItem("matrix-presents.homeserver") || undefined; 26 | this.accessToken = localStorage.getItem("matrix-presents.access_token") || undefined; 27 | this.deviceId = localStorage.getItem("matrix-presents.device_id") || undefined; 28 | this.isGuest = localStorage.getItem("matrix-presents.is_guest") === "true" || false; 29 | } 30 | 31 | public get defaultHomeserver(): string { 32 | return config.guest_homeserver || "https://matrix.org"; 33 | } 34 | 35 | public set userId(userId: string|null) { 36 | this.state.userId = userId; 37 | if (userId) { 38 | localStorage.setItem("matrix-presents.user_id", userId); 39 | return; 40 | } 41 | localStorage.removeItem("matrix-presents.user_id"); 42 | } 43 | 44 | public get userId(): string|null { 45 | return this.state.userId; 46 | } 47 | 48 | public set pageName(name) { 49 | document.title = this.state.pageName || "matrix-presents"; 50 | this.state.pageName = name; 51 | } 52 | 53 | public get pageName(): string|null { 54 | // TODO: maybe not a great place to hook it 55 | return this.state.pageName; 56 | } 57 | 58 | public set displayName(displayName: string|undefined) { 59 | this.state.displayName = displayName; 60 | if (displayName) { 61 | sessionStorage.setItem("matrix-presents.displayname", displayName); 62 | return; 63 | } 64 | sessionStorage.removeItem("matrix-presents.displayname"); 65 | } 66 | 67 | public get displayName(): string|undefined { 68 | return this.state.displayName; 69 | } 70 | 71 | public set accessToken(accessToken: string|undefined) { 72 | this.state.accessToken = accessToken; 73 | if (accessToken) { 74 | localStorage.setItem("matrix-presents.access_token", accessToken); 75 | return; 76 | } 77 | localStorage.removeItem("matrix-presents.device_id"); 78 | } 79 | 80 | public get accessToken(): string|undefined { 81 | return this.state.accessToken; 82 | } 83 | 84 | public set deviceId(deviceId: string|undefined) { 85 | this.state.deviceId = deviceId; 86 | if (deviceId) { 87 | localStorage.setItem("matrix-presents.device_id", deviceId); 88 | return; 89 | } 90 | localStorage.removeItem("matrix-presents.device_id"); 91 | } 92 | 93 | public get deviceId(): string|undefined { 94 | return this.state.deviceId; 95 | } 96 | 97 | public set homeserver(homeserver: string|undefined) { 98 | this.state.homeserver = homeserver; 99 | if (homeserver) { 100 | localStorage.setItem("matrix-presents.homeserver", homeserver); 101 | return; 102 | } 103 | localStorage.removeItem("matrix-presents.homeserver"); 104 | } 105 | 106 | public get homeserver(): string|undefined { 107 | return this.state.homeserver; 108 | } 109 | 110 | public set isGuest(isGuest: boolean) { 111 | this.state.isGuest = isGuest; 112 | localStorage.setItem("matrix-presents.is_guest", isGuest.toString()); 113 | } 114 | 115 | public get isGuest(): boolean { 116 | return this.state.isGuest; 117 | } 118 | 119 | public vapeLogin(): void { 120 | this.accessToken = undefined; 121 | this.userId = null; 122 | this.homeserver = undefined; 123 | this.isGuest = true; // Sort of wrong. 124 | this.deviceId = undefined; 125 | this.displayName = undefined; 126 | } 127 | } 128 | 129 | export default new Store(); 130 | -------------------------------------------------------------------------------- /src/vue-feather-icons.d.ts: -------------------------------------------------------------------------------- 1 | 2 | declare module "vue-feather-icons" { 3 | export class HomeIcon extends Vue { } 4 | export class RssIcon extends Vue { } 5 | export class PlusCircleIcon extends Vue { } 6 | export class EditIcon extends Vue { } 7 | export class UsersIcon extends Vue { } 8 | export class LockIcon extends Vue { } 9 | export class UnlockIcon extends Vue { } 10 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "strict": true, 6 | "jsx": "preserve", 7 | "importHelpers": true, 8 | "moduleResolution": "node", 9 | "experimentalDecorators": true, 10 | "esModuleInterop": true, 11 | "resolveJsonModule": true, 12 | "allowSyntheticDefaultImports": true, 13 | "sourceMap": true, 14 | "baseUrl": ".", 15 | "types": [ 16 | "webpack-env" 17 | ], 18 | "paths": { 19 | "@/*": [ 20 | "src/*" 21 | ] 22 | }, 23 | "lib": [ 24 | "esnext", 25 | "dom", 26 | "dom.iterable", 27 | "scripthost" 28 | ], 29 | }, 30 | "include": [ 31 | "src/**/*.ts", 32 | "src/**/*.tsx", 33 | "src/**/*.vue", 34 | "tests/**/*.ts", 35 | "tests/**/*.tsx" 36 | ], 37 | "exclude": [ 38 | "node_modules" 39 | ], 40 | } 41 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "warning", 3 | "extends": [ 4 | "tslint:recommended" 5 | ], 6 | "linterOptions": { 7 | "exclude": [ 8 | "node_modules/**" 9 | ] 10 | }, 11 | "rules": { 12 | "indent": [true, "spaces", 2], 13 | "interface-name": false, 14 | "no-consecutive-blank-lines": false, 15 | "object-literal-sort-keys": false, 16 | "ordered-imports": false, 17 | "quotemark": [true, "double"], 18 | "no-console": false 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | css: { 3 | loaderOptions: { 4 | sass: { 5 | prependData: ` 6 | @import "@/main.scss"; 7 | ` 8 | } 9 | } 10 | } 11 | }; --------------------------------------------------------------------------------