├── media └── matrix-rx-p2.gif ├── .gitignore ├── src ├── config.ts ├── async.ts ├── client.ts ├── storage.ts ├── chat-popup.tsx ├── index.ts ├── event-block.ts ├── autocomplete-configuration.ts └── watch.ts ├── tsconfig.json ├── roam-matrix.iml ├── README.md ├── package.json └── LICENSE /media/matrix-rx-p2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Stvad/roam-matrix/main/media/matrix-rx-p2.gif -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | dist 4 | out 5 | .env 6 | extension.js 7 | extension.js.LICENSE.txt 8 | 9 | # Local Netlify folder 10 | .netlify 11 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import {Page} from 'roam-api-wrappers/dist/data' 2 | 3 | export const configPageName = 'roam/js/matrix-rx' 4 | export const createConfigPage = async (name = configPageName) => { 5 | return Page.getOrCreate(name) 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/roamjs-scripts/default.tsconfig", 3 | "include": [ 4 | "src" 5 | ], 6 | "exclude": [ 7 | "node_modules" 8 | ], 9 | "compilerOptions": { 10 | "strict": true, 11 | "allowSyntheticDefaultImports": true, 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /roam-matrix.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # roam-matrix 2 | 3 | A [Matrix](https://matrix.org) client for RoamResearch. Built to explore ideas in https://vlad.roam.garden/Bringing-knowledge-and-conversation-closer-together 4 | 5 | ## Features 6 | 7 | - Send autocomplete configuration (only can be interpreted by a [custom matrix client](https://matrix-rx.netlify.app/) atm) 8 | - Monitor a room for new messages and save them to Roam 9 | 10 |  11 | 12 | --- 13 | 14 | Built on https://github.com/Stvad/matrix-rx 15 | -------------------------------------------------------------------------------- /src/async.ts: -------------------------------------------------------------------------------- 1 | export const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) 2 | 3 | export const memoize = any>(fn: T, rememberFor: number) => { 4 | let lastCallTime: number = 0 5 | let lastResult: ReturnType 6 | return (...args: Parameters): ReturnType => { 7 | const now = Date.now() 8 | if (now - lastCallTime > rememberFor) { 9 | lastResult = fn(...args) 10 | lastCallTime = now 11 | } 12 | return lastResult 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | import {Matrix, Credentials} from 'matrix-rx' 2 | 3 | // todo potentially differentiate on graph name, 4 | const credsKey = 'matrix-credentials-roam' 5 | 6 | // todo popup should be prompted by the lack of credentials 7 | export const saveCredentials = (credentials: Credentials) => { 8 | localStorage.setItem(credsKey, JSON.stringify(credentials)) 9 | } 10 | 11 | export const loadCredentials = (): Credentials | undefined => { 12 | const credentials = localStorage.getItem(credsKey) 13 | return credentials ? JSON.parse(credentials) : undefined 14 | } 15 | 16 | export const clientFromStoredCredentials = (): Matrix => { 17 | const credentials = loadCredentials() 18 | if (!credentials) throw new Error('No credentials found') 19 | 20 | return Matrix.fromCredentials(credentials) 21 | } 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "roam-matrix", 3 | "version": "1.0.0", 4 | "description": "Description for roam-matrix.", 5 | "main": "./build/main.js", 6 | "scripts": { 7 | "prebuild:roam": "yarn install", 8 | "build:roam": "roamjs-scripts build --depot", 9 | "build": "roamjs-scripts build", 10 | "deploy": "netlify deploy --prod -s roam-matrix.netlify.app -d build", 11 | "start:depot": "roamjs-scripts dev --depot", 12 | "start": "roamjs-scripts dev" 13 | }, 14 | "license": "MIT", 15 | "devDependencies": { 16 | "@types/lodash.samplesize": "^4.2.7", 17 | "@types/react": "^18.0.24", 18 | "@types/react-dom": "^18.0.8", 19 | "dotenv": "^16.0.3", 20 | "nearley": "^2.20.1", 21 | "roamjs-scripts": "^0.24.3" 22 | }, 23 | "dependencies": { 24 | "@blueprintjs/core": "^4.11.5", 25 | "lodash.samplesize": "^4.2.0", 26 | "matrix-rx": "../matrix-rx", 27 | "roam-api-wrappers": "^0.1.2", 28 | "roamjs-components": "^0.74.19" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/storage.ts: -------------------------------------------------------------------------------- 1 | import {Page} from 'roam-api-wrappers/dist/data' 2 | 3 | interface AsyncStorage { 4 | get(key: string): Promise 5 | set(key: string, value: string): Promise 6 | } 7 | 8 | export class ObjectStorage { 9 | constructor(private storage: AsyncStorage) { 10 | } 11 | 12 | async get(key: string, defaultValue?: T): Promise { 13 | const str = await this.storage.get(key) 14 | return str ? JSON.parse(str) as T : defaultValue 15 | } 16 | 17 | set = (key: string, value: T) => 18 | this.storage.set(key, JSON.stringify(value)) 19 | } 20 | 21 | export class RoamStorage implements AsyncStorage { 22 | constructor(private pageName: string) { 23 | } 24 | 25 | async get(key: string): Promise { 26 | return Page.fromName(this.pageName)?.childWithValue(key)?.children[0]?.text ?? null 27 | } 28 | 29 | async set(key: string, value: string) { 30 | const page = Page.fromName(this.pageName)! 31 | const block = await page.childAtPath([key, '0'], true) 32 | block!.text = value 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/chat-popup.tsx: -------------------------------------------------------------------------------- 1 | import {Classes, Dialog} from '@blueprintjs/core' 2 | import {Login, Room} from 'matrix-rx' 3 | import React from 'react' 4 | import {createOverlayRender} from 'roamjs-components/util' 5 | import {saveCredentials} from './client' 6 | 7 | interface ChatPopupProps { 8 | pageId: string | null | undefined; 9 | } 10 | 11 | export const ChatPopup = ({onClose, pageId}: { onClose: () => void; } & ChatPopupProps) => { 12 | return ( 13 | 20 | 21 | 22 | Chat {pageId} 23 | 24 | 25 | 26 | 27 | 28 | ) 29 | } 30 | 31 | // @ts-ignore 32 | export const RoomChatOverlay = createOverlayRender('autocomplete-prompt', ChatPopup) 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Vladyslav Sitalo 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 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import runExtension from 'roamjs-components/util/runExtension' 2 | import {RoomChatOverlay} from './chat-popup' 3 | import {sendAutocompleteConfiguration} from './autocomplete-configuration' 4 | import {startEventWatcher} from './watch' 5 | import {createConfigPage} from './config' 6 | 7 | export default runExtension({ 8 | run: async (args) => { 9 | const watchRoomId = 'matrix-watch-room-id' 10 | args.extensionAPI.settings.panel.create({ 11 | tabTitle: 'roam-matrix', 12 | settings: [ 13 | { 14 | id: watchRoomId, 15 | description: 'Room ID to watch', 16 | name: 'Room ID', 17 | action: { 18 | type: 'input', 19 | placeholder: '!room:matrix.org', 20 | }, 21 | 22 | }, 23 | ], 24 | }) 25 | 26 | await createConfigPage() 27 | 28 | const label = 'Page Chat' 29 | 30 | window.roamAlphaAPI.ui.commandPalette.addCommand({ 31 | label: label, 32 | callback: async () => RoomChatOverlay({pageId: await window.roamAlphaAPI.ui.mainWindow.getOpenPageOrBlockUid()}), 33 | }) 34 | 35 | // todo localstorage based on the graph name 36 | // todo what to do if there are several clients running? 37 | // @ts-ignore 38 | const roomIdToConfigure = window.matrixRxConfig.roomIdToWatch 39 | if (!roomIdToConfigure) { 40 | console.error('No roomIdToConfigure provided') 41 | return 42 | } 43 | 44 | void sendAutocompleteConfiguration(roomIdToConfigure) 45 | 46 | const stopWatching = await startEventWatcher(roomIdToConfigure) 47 | 48 | return () => { 49 | window.roamAlphaAPI.ui.commandPalette.removeCommand({label}) 50 | stopWatching() 51 | } 52 | }, 53 | }) 54 | -------------------------------------------------------------------------------- /src/event-block.ts: -------------------------------------------------------------------------------- 1 | import {AggregatedEvent} from 'matrix-rx' 2 | 3 | function getMessageText(it: AggregatedEvent, homeServerDomain: string) { 4 | if (it.content.msgtype === 'm.audio') { 5 | return getAudioText(it, homeServerDomain) 6 | } else if (it.content.msgtype === 'm.image') { 7 | return getImageText(it, homeServerDomain) 8 | } else { 9 | return unwrapLinks(it.content.body!) 10 | } 11 | } 12 | 13 | export function createBlockFromEvent(it: AggregatedEvent, roomId: string, homeServerDomain: string) { 14 | const text = getMessageText(it, homeServerDomain) 15 | 16 | const uid = uidFromEventId(it.event_id) 17 | return { 18 | uid, 19 | text, 20 | children: [ 21 | { 22 | uid: 'a' + uid.slice(1), 23 | text: `author::[[${it.sender}]]`, 24 | }, 25 | { 26 | uid: 't' + uid.slice(1), 27 | text: `timestamp::${new Date(it.origin_server_ts).toLocaleString()}`, 28 | }, 29 | { 30 | uid: 'U' + uid.slice(1), 31 | text: `URL::https://matrix.to/#/${roomId}/${it.event_id}`, 32 | }, 33 | ], 34 | } 35 | } 36 | 37 | /** 38 | * Starts with $ which we want to remove, and 9 is the length of the block uid in Roam 39 | */ 40 | const uidFromEventId = (eventId: string) => eventId.slice(1, 10) 41 | 42 | function unwrapLinks(text: string) { 43 | const graphId = window.roamAlphaAPI.graph.name 44 | 45 | const regex = new RegExp(`\\[(.*?)]\\(https:\\/\\/roamresearch\\.com\\/#\\/app\\/${graphId}\\/page\\/[a-zA-Z-_0-9]+?\\)`, 'g') 46 | return text.replaceAll(regex, '$1') 47 | } 48 | 49 | const getAudioText = (it: AggregatedEvent, homeServerDomain: string) => 50 | `{{[[audio]]: ${mxcToHttpUrl(it.content.url!, homeServerDomain)} }}` 51 | 52 | const getImageText = (it: AggregatedEvent, homeServerDomain: string) => 53 | `})` 54 | 55 | 56 | /** 57 | * 58 | * @param mxcUrl: like mxc://matrix.org/uid 59 | */ 60 | function mxcToHttpUrl(mxcUrl: string, homeserverDomain: string) { 61 | const mxcPrefix = 'mxc://' 62 | if (!mxcUrl.startsWith(mxcPrefix)) { 63 | throw new Error('Invalid mxc URL') 64 | } 65 | 66 | const mxcUrlWithoutScheme = mxcUrl.slice(mxcPrefix.length) 67 | return `https://${homeserverDomain}/_matrix/media/v3/download/${mxcUrlWithoutScheme}` 68 | } 69 | 70 | -------------------------------------------------------------------------------- /src/autocomplete-configuration.ts: -------------------------------------------------------------------------------- 1 | import {Matrix} from 'matrix-rx' 2 | 3 | import {Page, Roam} from 'roam-api-wrappers/dist/data' 4 | import {loadCredentials} from './client' 5 | import sampleSize from 'lodash.samplesize' 6 | 7 | export async function sendAutocompleteConfiguration(roomId: string) { 8 | const credentials = loadCredentials() 9 | if (!credentials) return 10 | 11 | const autocompletePages = getAutocompletePages() 12 | 13 | const matrix = Matrix.fromCredentials(credentials) 14 | 15 | // todo make this incremental, so I don't have to re-send all the chunks each time 16 | const chunks = chunkPages(autocompletePages) 17 | console.log('Splitting autocomplete configuration into', chunks.length, 'chunks') 18 | 19 | const graphId = window.roamAlphaAPI.graph.name 20 | 21 | for (const [idx, chunk] of chunks.entries()) { 22 | await matrix.sendStateEvent(roomId, { 23 | type: 'matrix-rx.autocomplete', 24 | state_key: `roam.autocomplete-pages.${graphId}.${idx}`, 25 | content: { 26 | pages: chunk, 27 | urlPattern: `https://roamresearch.com/#/app/${graphId}/page/{{id}}`, 28 | }, 29 | }) 30 | } 31 | } 32 | 33 | function getAutocompletePages() { 34 | const allPages = Roam.listPages().map(it => new Page(it)) 35 | const notSrsPage = (it: Page) => !(it.text.includes('[[interval]]') || it.text.includes('[[factor]]')) 36 | 37 | return allPages 38 | .filter(notSrsPage) 39 | // we want a stable order for incremental updates 40 | // todo sample prevents the consistent chunk size, need to figure that out - maybe derive size only when re-sending everything? 41 | // or re-send everything only if chunk size changes significantly 🤔 42 | .sort((a, b) => a.createdTime - b.createdTime) 43 | .map(it => ({ 44 | text: it.text, 45 | id: it.uid, 46 | // todo this is not great perf wise, and probs not a great summary either 47 | summary: it.children[0]?.text, 48 | })) 49 | } 50 | 51 | function chunkPages(array: T[]) { 52 | console.log('chunking', array.length) 53 | const chunkSize = estimateNumberOfObjectsInChunk(array) 54 | const chunks = [] 55 | for (let i = 0; i < array.length; i += chunkSize) { 56 | chunks.push(array.slice(i, i + chunkSize)) 57 | } 58 | console.log({chunkSize, numberOf: chunks.length}) 59 | return chunks 60 | } 61 | 62 | /** By spec 65kb is the max size of the event 63 | * giving a bit of a buffer setting it to 60kb 64 | */ 65 | function estimateNumberOfObjectsInChunk(arr: any[], chunkSizeInBytes: number = 60 * 1024) { 66 | const sample = sampleSize(arr, 10) 67 | const sizes = sample.map(getObjectSize) 68 | console.log({sample, sizes}) 69 | const objectSize = Math.max(...sizes) 70 | return Math.ceil(chunkSizeInBytes / objectSize) 71 | } 72 | 73 | function getObjectSize(obj: any) { 74 | const str = JSON.stringify(obj) 75 | return new TextEncoder().encode(str).length 76 | } 77 | -------------------------------------------------------------------------------- /src/watch.ts: -------------------------------------------------------------------------------- 1 | import {filter} from 'rxjs' 2 | import {clientFromStoredCredentials, loadCredentials} from './client' 3 | import {AggregatedEvent, EventsSince} from 'matrix-rx' 4 | import {Block, Page} from 'roam-api-wrappers/dist/data' 5 | import {RoamDate} from 'roam-api-wrappers/dist/date' 6 | import {tap} from 'rxjs/operators' 7 | import {ObjectStorage, RoamStorage} from './storage' 8 | import {memoize} from './async' 9 | import {configPageName} from './config' 10 | import {createBlockFromEvent} from './event-block' 11 | 12 | export const watchMessages = (roomId: string, since?: EventsSince) => 13 | watchEvents(roomId, since).pipe(filter(it => it.type === 'm.room.message')) 14 | 15 | export const watchEvents = (roomId: string, since?: EventsSince) => 16 | clientFromStoredCredentials().room(roomId, since).watchEventValues() 17 | 18 | class MessageWatcher { 19 | storageKey = `matrix.room.${this.roomId}.lastEvent` 20 | 21 | constructor(private roomId: string, private store: ObjectStorage = new ObjectStorage(new RoamStorage(configPageName))) { 22 | } 23 | 24 | private getLastEvent() { 25 | return this.store.get(this.storageKey) 26 | } 27 | 28 | private setLastEvent(value: AggregatedEvent | undefined) { 29 | return this.store.set(this.storageKey, value) 30 | } 31 | 32 | async watch() { 33 | const updateLastEvent = tap(async (it: AggregatedEvent) => { 34 | const lastEvent = await this.getLastEvent() 35 | if (it.origin_server_ts > (lastEvent?.origin_server_ts ?? 0)) { 36 | this.setLastEvent(it) 37 | } 38 | }) 39 | 40 | const lastEvent = await this.getLastEvent() 41 | // I wonder if this can end up problematic (are events guaranteed to be always in order?) 42 | console.log('watching starting from', lastEvent?.event_id) 43 | return watchMessages(this.roomId, lastEvent?.event_id ? { 44 | eventId: lastEvent?.event_id, 45 | timestamp: lastEvent?.origin_server_ts, 46 | } : undefined) 47 | .pipe(updateLastEvent) 48 | } 49 | } 50 | 51 | async function todaysLogBlock(): Promise { 52 | const todayName = RoamDate.toRoam(new Date()) 53 | const today = Page.fromName(todayName)! 54 | 55 | const blockText = '[[matrix-messages]]' 56 | const existing = today.childWithValue(blockText) 57 | if (existing) return existing 58 | 59 | return today.appendChild(blockText) 60 | } 61 | 62 | export const startEventWatcher = async (roomId: string): Promise<() => void> => { 63 | const messages = await new MessageWatcher(roomId).watch() 64 | /** 65 | * This rn handles only the "create block when there are many initial messages" case. 66 | * May be an overcomplicated way of solving it 🤔 67 | */ 68 | const getBlock = memoize(todaysLogBlock, 1000 * 5) 69 | 70 | const subscription = messages.subscribe(async (it: AggregatedEvent) => { 71 | const block = await getBlock() 72 | block.appendChild(createBlockFromEvent(it, roomId, loadCredentials()?.homeServer!)) 73 | }) 74 | 75 | return () => subscription.unsubscribe() 76 | } 77 | --------------------------------------------------------------------------------