├── .babelrc ├── .eslintrc ├── .github └── workflows │ └── CI.yml ├── .gitignore ├── .nvmrc ├── .vscode └── launch.json ├── LICENSE.md ├── README.md ├── babel.config.js ├── chatPreferencesForm.ttl ├── dev ├── context.ts ├── index.html └── index.js ├── jest.config.js ├── jest.setup.ts ├── package-lock.json ├── package.json ├── shapes ├── chat-shapes.shex └── chat-shapes.ttl ├── src ├── create.ts ├── longChatPane.js ├── longChatPane.test.ts ├── main.js └── shortChatPane.js ├── webpack.config.js └── webpack.dev.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-typescript" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true 6 | }, 7 | "globals": { 8 | "Atomics": "readonly", 9 | "SharedArrayBuffer": "readonly" 10 | }, 11 | "rules": { 12 | "no-unused-vars": ["warn", { 13 | "argsIgnorePattern": "^_", 14 | "varsIgnorePattern": "^_" 15 | }] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: CI 5 | 6 | on: 7 | push: 8 | branches: 9 | - "**" 10 | pull_request: 11 | branches: 12 | - "**" 13 | workflow_dispatch: 14 | 15 | jobs: 16 | build: 17 | 18 | runs-on: ubuntu-latest 19 | 20 | strategy: 21 | matrix: 22 | node-version: 23 | - 18.x 24 | - 20.x 25 | 26 | steps: 27 | - uses: actions/checkout@v2 28 | - name: Use Node.js ${{ matrix.node-version }} 29 | uses: actions/setup-node@v1 30 | with: 31 | node-version: ${{ matrix.node-version }} 32 | - run: npm ci 33 | - run: npm run lint --if-present 34 | - run: npm test 35 | - run: npm run build --if-present 36 | - name: Save build 37 | if: matrix.node-version == '18.x' 38 | uses: actions/upload-artifact@v4 39 | with: 40 | name: build 41 | path: | 42 | . 43 | !node_modules 44 | retention-days: 1 45 | 46 | npm-publish-build: 47 | needs: build 48 | runs-on: ubuntu-latest 49 | steps: 50 | - uses: actions/download-artifact@v4 51 | with: 52 | name: build 53 | - uses: actions/setup-node@v1 54 | with: 55 | node-version: 18.x 56 | - uses: rlespinasse/github-slug-action@v3.x 57 | - name: Append commit hash to package version 58 | run: 'sed -i -E "s/(\"version\": *\"[^\"]+)/\1-${GITHUB_SHA_SHORT}/" package.json' 59 | - name: Disable pre- and post-publish actions 60 | run: 'sed -i -E "s/\"((pre|post)publish)/\"ignore:\1/" package.json' 61 | - uses: JS-DevTools/npm-publish@v1 62 | with: 63 | token: ${{ secrets.NPM_TOKEN }} 64 | tag: ${{ env.GITHUB_REF_SLUG }} 65 | 66 | npm-publish-latest: 67 | needs: build 68 | runs-on: ubuntu-latest 69 | if: github.ref == 'refs/heads/main' 70 | steps: 71 | - uses: actions/download-artifact@v4 72 | with: 73 | name: build 74 | - uses: actions/setup-node@v1 75 | with: 76 | node-version: 18.x 77 | - name: Disable pre- and post-publish actions 78 | run: 'sed -i -E "s/\"((pre|post)publish)/\"ignore:\1/" package.json' 79 | - uses: JS-DevTools/npm-publish@v1 80 | with: 81 | token: ${{ secrets.NPM_TOKEN }} 82 | tag: latest 83 | -------------------------------------------------------------------------------- /.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 | # Webpack 15 | dist 16 | 17 | # Babel 18 | lib 19 | 20 | # Directory for instrumented libs generated by jscoverage/JSCover 21 | lib-cov 22 | 23 | # Coverage directory used by tools like istanbul 24 | coverage 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # TypeScript v1 declaration files 46 | typings/ 47 | 48 | # Optional npm cache directory 49 | .npm 50 | 51 | # Optional eslint cache 52 | .eslintcache 53 | 54 | # Optional REPL history 55 | .node_repl_history 56 | 57 | # Output of 'npm pack' 58 | *.tgz 59 | 60 | # Yarn Integrity file 61 | .yarn-integrity 62 | 63 | # dotenv environment variables file 64 | .env 65 | 66 | # next.js build output 67 | .next 68 | 69 | # JetBrains based IDEs, such as WebStorm 70 | .idea 71 | 72 | # Visual Code Studio 73 | .history/ 74 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v18.19.0 2 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "chrome", 9 | "request": "launch", 10 | "name": "Launch Chrome against localhost", 11 | "url": "http://localhost:8080", 12 | "webRoot": "${workspaceFolder}" 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 - present 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 | # chat-pane 2 | 3 | Solid-compatible chat discussion applet for solid-panes framework 4 | 5 | Extracted from the solid-panes monolithic repository. 6 | 7 | Do add your wishlists to the issue list for a solid based (safe based etc) chat system. Things other chat systems you ave seen do, Things you can imagine a solid chat system doing because it is linked data, things you think would really help people collaborate 8 | 9 | You can build with `npm install && npm run build && cd dist && npx serve`. 10 | You can debug with VSCode + Chrome (see `.vscode/launch.json`). 11 | 12 | ## Development 13 | `npm run dev` 14 | 15 | ## Deploy stand-alone 16 | 17 | You can deploy this code as a stand-alone Solid app. 18 | The way to do that depends on your html-app hosting provider. 19 | For instance, to deploy to https://solid-chat.5apps.com/ you would: 20 | 21 | ```sh 22 | git checkout deploy # this branch has dist/ commented out in .gitignore 23 | git merge master 24 | npm ci 25 | npm run build 26 | git add dist/ 27 | git commit --no-verify -am"build" 28 | git remote add 5apps git@5apps.com:michiel_chat.git 29 | git push 5apps deploy:master 30 | ``` -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | targets: { 7 | node: "current", 8 | }, 9 | }, 10 | ], 11 | "@babel/preset-typescript", 12 | ], 13 | }; 14 | -------------------------------------------------------------------------------- /chatPreferencesForm.ttl: -------------------------------------------------------------------------------- 1 | @prefix rdf: . 2 | @prefix solid: . 3 | @prefix ui: . 4 | @prefix : <#>. 5 | 6 | :this 7 | "Chat preferences" ; 8 | a ui:Form ; 9 | ui:parts ( :colorizeByAuthor :expandImagesInline :newestFirst :inlineImageHeightEms 10 | :shiftEnterSendsMessage :authorDateOnLeft :showDeletedMessages). 11 | 12 | :colorizeByAuthor a ui:TristateField; ui:property solid:colorizeByAuthor; 13 | ui:label "Color user input by user". 14 | 15 | :expandImagesInline a ui:TristateField; ui:property solid:expandImagesInline; 16 | ui:label "Expand image URLs inline". 17 | 18 | :newestFirst a ui:TristateField; ui:property solid:newestFirst; 19 | ui:label "Newest messages at the top". 20 | 21 | :inlineImageHeightEms a ui:IntegerField; ui:property solid:inlineImageHeightEms; 22 | ui:label "Inline image height (lines)". 23 | 24 | :shiftEnterSendsMessage a ui:TristateField; ui:property solid:shiftEnterSendsMessage; 25 | ui:label "Shift-Enter sends message". 26 | 27 | :authorDateOnLeft a ui:TristateField; ui:property solid:authorDateOnLeft; 28 | ui:label "Author & date of message on left". 29 | 30 | :showDeletedMessages a ui:TristateField; ui:property solid:showDeletedMessages; 31 | ui:label "Show placeholders for deleted messages". 32 | -------------------------------------------------------------------------------- /dev/context.ts: -------------------------------------------------------------------------------- 1 | import { DataBrowserContext, PaneRegistry } from "pane-registry"; 2 | import { solidLogicSingleton, store } from "solid-logic"; 3 | import { longChatPane } from "../src/longChatPane"; 4 | import { LiveStore } from 'rdflib' 5 | 6 | export const context: DataBrowserContext = { 7 | session: { 8 | store: store as LiveStore, 9 | paneRegistry: { 10 | byName: (name: string) => { 11 | return longChatPane 12 | } 13 | } as PaneRegistry, 14 | logic: solidLogicSingleton 15 | }, 16 | dom: document, 17 | getOutliner: () => null, 18 | }; 19 | 20 | export const fetcher = store.fetcher; 21 | -------------------------------------------------------------------------------- /dev/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Solid Chat 6 | 17 | 18 | 19 |

Solid Chat

20 |
21 |
22 |
23 |
24 |

25 | A handy app for chatting with other Solid users 26 |

27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /dev/index.js: -------------------------------------------------------------------------------- 1 | import { store, authn, authSession } from 'solid-logic' 2 | import * as $rdf from 'rdflib' 3 | import * as UI from 'solid-ui' 4 | import { longChatPane } from '../src/longChatPane.js' 5 | import { getChat } from '../src/create.ts' 6 | import { context, fetcher } from './context' 7 | 8 | const loginBanner = document.getElementById('loginBanner') 9 | const webId = document.getElementById('webId') 10 | 11 | loginBanner.appendChild(UI.login.loginStatusBox(document, null, {})) 12 | 13 | async function finishLogin () { 14 | await authSession.handleIncomingRedirect() 15 | const session = authSession 16 | if (session.info.isLoggedIn) { 17 | // Update the page with the status. 18 | webId.innerHTML = 'Logged in as: ' + authn.currentUser().uri 19 | } else { 20 | webId.innerHTML = '' 21 | } 22 | } 23 | 24 | finishLogin() 25 | 26 | const menuDiv = document.createElement('div') 27 | 28 | async function renderMenuDiv () { 29 | console.log('get invites list') 30 | menuDiv.innerHTML = await getInvitesList() 31 | console.log('get chats list') 32 | menuDiv.innerHTML += await getChatsList() 33 | } 34 | 35 | window.followLink = async function (from, follow, multiple) { 36 | const subject = $rdf.sym(from) 37 | const doc = subject.doc() 38 | await new Promise((resolve, reject) => { 39 | store.fetcher.load(doc).then(resolve, reject) 40 | }) 41 | const predicate = $rdf.sym(follow) 42 | if (multiple) { 43 | return store.each(subject, predicate).map(n => n.value) 44 | } 45 | return store.any(subject, predicate).value 46 | } 47 | 48 | function toLi (uri) { 49 | return `
  • ${uri}
  • ` 50 | } 51 | 52 | async function getChatsList () { 53 | const { instances } = await UI.login.findAppInstances({}, $rdf.sym('http://www.w3.org/ns/pim/meeting#LongChat')) 54 | return `

    Your chats: 55 |
      56 | ${instances.map(n => n.value).map(toLi)} 57 |
    ` 58 | } 59 | 60 | window.inviteSomeone = async function () { 61 | const invitee = store.namedNode(document.getElementById('invitee').value) 62 | const created = await getChat(invitee) 63 | console.log(created) 64 | renderMenuDiv() 65 | } 66 | 67 | async function getInvitesList () { 68 | const webId = authSession.webId 69 | const globalInbox = await window.followLink(webId, UI.ns.ldp('inbox')) 70 | const inboxItems = await window.followLink(globalInbox, UI.ns.ldp('contains'), true) 71 | const invites = [] 72 | const promises = inboxItems.map(async x => { 73 | try { 74 | const inboxMsgTypes = await window.followLink(x, UI.ns.rdf('type'), true) 75 | const isLongChatInvite = (inboxMsgTypes.indexOf('http://www.w3.org/ns/pim/meeting#LongChatInvite') !== -1) 76 | if (isLongChatInvite) { 77 | console.log('new chat!', x) 78 | const chatUrl = await window.followLink(x, UI.ns.rdf('seeAlso')) 79 | invites.push(chatUrl) 80 | } 81 | } catch (e) { 82 | console.error('Failed to parse inbox item', x) 83 | } 84 | }) 85 | await Promise.all(promises) 86 | return `

    Your Invites: 87 |
      88 | ${invites.map(toLi)} 89 |
    90 | Invite someone: ` 91 | } 92 | 93 | async function appendChatPane (dom, uri) { 94 | const subject = $rdf.sym(uri) 95 | const doc = subject.doc() 96 | 97 | await new Promise((resolve, reject) => { 98 | store.fetcher.load(doc).then(resolve, reject) 99 | }) 100 | 101 | const options = {} 102 | renderMenuDiv() 103 | dom.body.appendChild(menuDiv) 104 | console.log('chat', subject) 105 | const paneDiv = longChatPane.render(subject, context, options) 106 | dom.body.appendChild(paneDiv) 107 | } 108 | 109 | const webIdToShow = 'https://solidos.solidcommunity.net/Team/SolidOs%20team%20chat/index.ttl#this' 110 | 111 | fetcher.load(webIdToShow).then(() => { 112 | appendChatPane(document, webIdToShow) 113 | }) 114 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'jsdom', 3 | setupFilesAfterEnv: ["./jest.setup.ts"], 4 | transformIgnorePatterns: ["/node_modules/(?!lit-html).+\\.js"], 5 | testEnvironmentOptions: { 6 | customExportConditions: ['node'] 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /jest.setup.ts: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | import fetchMock from "jest-fetch-mock"; 3 | const { TextEncoder, TextDecoder } = require('util') 4 | 5 | global.TextEncoder = TextEncoder; 6 | global.TextDecoder = TextDecoder 7 | 8 | fetchMock.enableMocks(); 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chat-pane", 3 | "version": "2.4.27", 4 | "description": "Solid-compatible Panes: Chat", 5 | "main": "./lib/main.js", 6 | "files": [ 7 | "src", 8 | "lib", 9 | "dist" 10 | ], 11 | "scripts": { 12 | "build": "npm run clean && webpack && npm run build-lib", 13 | "build-lib": "babel src -d lib --source-maps --extensions '.ts,.js'", 14 | "clean": "rm -rf lib && rm -rf dist", 15 | "test": "npm run lint && jest", 16 | "lint": "eslint '*.js'", 17 | "lint-fix": "eslint '*.js' --fix", 18 | "prepublishOnly": "npm test && npm run build && npm run build-lib", 19 | "postpublish": "git push origin main --follow-tags", 20 | "watch": "webpack --watch", 21 | "start": "webpack serve --open", 22 | "dev": "webpack serve --config webpack.dev.config.js --open" 23 | }, 24 | "repository": { 25 | "type": "git", 26 | "url": "https://github.com/solid/chat-pane" 27 | }, 28 | "keywords": [ 29 | "solid", 30 | "chat", 31 | "message", 32 | "discusssion", 33 | "decentralized", 34 | "web", 35 | "rdf", 36 | "ldp", 37 | "linked", 38 | "pane", 39 | "app", 40 | "data" 41 | ], 42 | "author": "Tim Berners-Lee ", 43 | "license": "MIT", 44 | "bugs": { 45 | "url": "https://github.com/solid/chat-pane/issues" 46 | }, 47 | "homepage": "https://github.com/solid/chat-pane", 48 | "dependencies": { 49 | "rdflib": "^2.2.36", 50 | "solid-logic": "^3.0.8", 51 | "solid-ui": "^2.5.1" 52 | }, 53 | "devDependencies": { 54 | "@babel/cli": "^7.26.4", 55 | "@babel/core": "^7.26.7", 56 | "@babel/preset-env": "^7.26.7", 57 | "@babel/preset-typescript": "^7.26.0", 58 | "@testing-library/dom": "^9.3.4", 59 | "@testing-library/jest-dom": "^5.17.0", 60 | "@types/jest": "^29.5.14", 61 | "babel-jest": "^29.7.0", 62 | "babel-loader": "^8.4.1", 63 | "buffer": "^6.0.3", 64 | "eslint": "^8.57.1", 65 | "html-webpack-plugin": "^5.6.3", 66 | "husky": "^7.0.4", 67 | "jest": "^29.7.0", 68 | "jest-environment-jsdom": "^29.7.0", 69 | "jest-fetch-mock": "^3.0.3", 70 | "lint-staged": "^12.5.0", 71 | "node-polyfill-webpack-plugin": "^2.0.1", 72 | "typescript": "^4.9.5", 73 | "webpack": "^5.97.1", 74 | "webpack-cli": "^4.10.0", 75 | "webpack-dev-server": "^4.15.2" 76 | }, 77 | "husky": { 78 | "hooks": { 79 | "pre-commit": "lint-staged", 80 | "pre-push": "npm test" 81 | } 82 | }, 83 | "lint-staged": { 84 | "*.js": [ 85 | "eslint" 86 | ] 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /shapes/chat-shapes.shex: -------------------------------------------------------------------------------- 1 | # Platform ontologies: 2 | @prefix rdf: . 3 | @prefix rdfs: . 4 | @prefix owl: . 5 | @prefix sh: . 6 | @prefix xsd: . 7 | 8 | # Domain ontologies 9 | # @prefix vcard: . 10 | 11 | @prefix dc: . 12 | @prefix foaf: . 13 | @prefix terms: . 14 | @prefix flow: . 15 | @prefix ical: . 16 | @prefix mee: . 17 | @prefix schema: . 18 | @prefix sioc: . 19 | @prefix solid: . 20 | @prefix ui: . 21 | 22 | @prefix : <#>. 23 | 24 | # This file is based on: https://github.com/solid/chat-pane/blob/chat-ui/shapes/chat-shapes.ttl 25 | #This is a shape file for Solid Chat 26 | # 27 | # with data like: 28 | # 29 | #:this 30 | # a mee:LongChat; 31 | # n2:author c:i; 32 | # n2:created "2018-07-06T21:36:04Z"^^XML:dateTime; 33 | # n2:title "Chat channel"; 34 | # flow:participation 35 | # :id1530912972126, :id1538415256782, :id1538415459106 . 36 | # 37 | # :id1530912972126 38 | # ic:dtstart "2018-07-06T21:36:12Z"^^XML:dateTime; 39 | # flow:participant c:i; 40 | # terms:expandImagesInline true; 41 | # ui:backgroundColor "#c1d0c8". 42 | # and in the dated chat file: 43 | # :id1549976046538 a schem:AgreeAction; schem:agent c:i; schem:target :Msg1549975677890. 44 | # 45 | # :Msg1549975677890 46 | # terms:created "2019-02-12T12:47:57Z"^^XML:dateTime; 47 | # n:content "Tuesday"; 48 | # n0:maker c:i. 49 | 50 | 51 | 52 | ##################### Chat Channel 53 | 54 | :ChatChannelShape EXTRA a { 55 | a [ mee:LongChat ] ; 56 | dc:author . + ; 57 | dc:title xsd:string ; 58 | dc:created xsd:dateTime ; 59 | ui:sharedPreferences . * ; # It would be better to refer a shape that defines preferences 60 | flow:participation . * ; # It would be better to refer a shape that defines participation 61 | } 62 | 63 | ################ Shared Preferences 64 | 65 | :SharedPreferencesShape { 66 | solid:expandImagesInline xsd:boolean ? ; 67 | solid:inlineImageHeightEms xsd:integer ? ; 68 | solid:newestFirst xsd:boolean ? 69 | } 70 | 71 | ################ Participation objects 72 | 73 | :ParticipationShape { 74 | ical:dtstart xsd:dateTime ; 75 | flow:participant . ; 76 | solid:colorizeByAuthor xsd:boolean ? ; 77 | solid:expandImagesInline xsd:boolean ? ; 78 | solid:inlineImageHeightEms xsd:integer ? ; 79 | solid:newestFirst xsd:boolean ? ; 80 | ui:backgroundColor xsd:string /#[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]/ ? 81 | } 82 | 83 | :MessageShape { 84 | terms:created xsd:dateTime ; 85 | foaf:maker . ; 86 | sioc:content xsd:string ; 87 | ^flow:message . ; 88 | } 89 | 90 | ########## Actions express sentiments 91 | 92 | :ActionShape { 93 | rdf:type . ; # object Must be subclass of schema:Action 94 | schema:agent . ? 95 | } 96 | -------------------------------------------------------------------------------- /shapes/chat-shapes.ttl: -------------------------------------------------------------------------------- 1 | # Platform ontologies: 2 | @prefix rdf: . 3 | @prefix rdfs: . 4 | @prefix owl: . 5 | @prefix sh: . 6 | @prefix xsd: . 7 | 8 | # Domain ontologies 9 | # @prefix vcard: . 10 | 11 | @prefix dc: . 12 | @prefix foaf: . 13 | @prefix terms: . 14 | @prefix flow: . 15 | @prefix ical: . 16 | @prefix mee: . 17 | @prefix schema: . 18 | @prefix sioc: . 19 | @prefix solid: . 20 | @prefix ui: . 21 | 22 | @prefix : <#>. 23 | 24 | # Apr 2019. Changed sh:count by sh:minCount/sh:maxCount 25 | 26 | ##################### Chat Channel 27 | 28 | <> rdfs:comment """This is a shape file for Solid Chat 29 | 30 | with data like: 31 | 32 | :this 33 | a mee:LongChat; 34 | n2:author c:i; 35 | n2:created "2018-07-06T21:36:04Z"^^XML:dateTime; 36 | n2:title "Chat channel"; 37 | flow:participation 38 | :id1530912972126, :id1538415256782, :id1538415459106 . 39 | 40 | :id1530912972126 41 | ic:dtstart "2018-07-06T21:36:12Z"^^XML:dateTime; 42 | flow:participant c:i; 43 | terms:expandImagesInline true; 44 | ui:backgroundColor "#c1d0c8". 45 | 46 | 47 | and in the dated chat file: 48 | 49 | 50 | :id1549976046538 51 | a schem:AgreeAction; schem:agent c:i; schem:target :Msg1549975677890. 52 | 53 | :Msg1549975677890 54 | terms:created "2019-02-12T12:47:57Z"^^XML:dateTime; 55 | n:content "Tuesday"; 56 | n0:maker c:i. 57 | """ . 58 | 59 | :ChatChannelShape sh:targetNode :this . 60 | 61 | :ChatChannelShape a sh:NodeShape ; 62 | sh:targetClass mee:LongChat ; 63 | sh:property 64 | 65 | [ sh:path rdf:type ; 66 | sh:value mee:LongChat; 67 | sh:count 1 ], 68 | 69 | [ sh:path dc:author ; 70 | sh:count 1], 71 | 72 | [ sh:path dc:title; 73 | sh:datatype xsd:string; 74 | sh:count 1 ], 75 | 76 | [ sh:path dc:created; 77 | sh:datatype xsd:dateTime; 78 | sh:count 1], 79 | 80 | [ sh:path ui:sharedPreferences ; 81 | sh:count 1], 82 | 83 | [ sh:path flow:participation ; 84 | sh:minCount 0 ] . 85 | 86 | ################ Shared Preferences 87 | 88 | :SharedPreferencesShape a sh:NodeShape; 89 | sh:targetObjectsOf ui:sharedPreferences; 90 | 91 | sh:property [ 92 | sh:path solid:expandImagesInline; 93 | sh:datatype xsd:boolean; 94 | sh:maxCount 1; 95 | ]; 96 | 97 | sh:property [ 98 | sh:path solid:inlineImageHeightEms; 99 | sh:datatype xsd:integer; 100 | sh:minCount 0; 101 | sh:maxCount 1; 102 | ]; 103 | 104 | sh:property [ 105 | sh:path solid:newestFirst; 106 | sh:datatype xsd:boolean; 107 | sh:minCount 0; 108 | sh:maxCount 1; 109 | ] . 110 | 111 | 112 | 113 | ################ Participation objects 114 | 115 | :ParticipationShape a sh:NodeShape; 116 | sh:targetObjectsOf flow:participation; 117 | 118 | sh:property [ 119 | sh:path ical:dtstart; 120 | sh:datatype xsd:dateTime; 121 | sh:minCount 1 ; 122 | sh:maxCount 1 ; 123 | ]; 124 | 125 | sh:property [ 126 | sh:path flow:participant; 127 | sh:minCount 1; 128 | sh:maxCount 1; 129 | ]; 130 | 131 | sh:property [ 132 | sh:path solid:colorizeByAuthor; 133 | sh:datatype xsd:boolean; 134 | sh:maxCount 1; 135 | ]; 136 | 137 | # Participants can store their personal preferences for this chat 138 | 139 | sh:property [ 140 | sh:path solid:expandImagesInline; 141 | sh:datatype xsd:boolean; 142 | sh:maxCount 1; 143 | ]; 144 | 145 | sh:property [ 146 | sh:path solid:inlineImageHeightEms; 147 | sh:datatype xsd:integer; 148 | sh:minCount 0; 149 | sh:maxCount 1; 150 | ]; 151 | 152 | sh:property [ 153 | sh:path solid:newestFirst; 154 | sh:datatype xsd:boolean; 155 | sh:minCount 0; 156 | sh:maxCount 1; 157 | ]; 158 | 159 | sh:property [ 160 | sh:path ui:backgroundColor; 161 | sh:minCount 0; 162 | sh:maxCount 1; 163 | sh:datatype xsd:string; 164 | sh:pattern "#[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]" ; 165 | ]; 166 | . 167 | 168 | ########## Messages 169 | 170 | 171 | :MessageShape a sh:NodeShape; 172 | sh:targetObjectsOf flow:message; 173 | 174 | sh:property 175 | [ sh:path terms:created; 176 | sh:minCount 1; 177 | sh:maxCount 1; 178 | sh:datatype xsd:dateTime 179 | ], 180 | 181 | [ sh:path foaf:maker; 182 | sh:minCount 1; 183 | sh:maxCount 1; 184 | ], 185 | 186 | [ sh:path sioc:content; 187 | sh:datatype xsd:string; 188 | sh:minCount 1; 189 | sh:maxCount 1 190 | ]; 191 | 192 | [ sh:path [ sh:inversePath flow:message ] ; 193 | sh:minCount 1; sh:maxCount 1 194 | ] . 195 | 196 | 197 | ########## Actions express sentiments 198 | 199 | :ActionShape a sh:NodeShape; 200 | sh:targetSubjectsOf schema:target; 201 | 202 | sh:property [ 203 | sh:path rdf:type; # object Must be subclass of schema:Action 204 | sh:minCount 1; 205 | sh:maxCount 1; 206 | ]; 207 | 208 | sh:property [ 209 | sh:path schema:agent; 210 | sh:minCount 1; 211 | sh:maxCount 1; 212 | ]; 213 | 214 | sh:property [ 215 | sh:path schema:target; 216 | sh:minCount 1; 217 | sh:maxCount 1; 218 | ] . 219 | 220 | 221 | # ends 222 | -------------------------------------------------------------------------------- /src/create.ts: -------------------------------------------------------------------------------- 1 | import { ns, widgets } from 'solid-ui' 2 | import { authn, store } from 'solid-logic' 3 | import { NamedNode, st } from 'rdflib' 4 | import { longChatPane } from './longChatPane' 5 | 6 | async function getMe () { 7 | const me = authn.currentUser() 8 | if (me === null) { 9 | throw new Error('Current user not found! Not logged in?') 10 | } 11 | await store.fetcher.load(me.doc()) 12 | return me 13 | } 14 | 15 | async function getPodRoot (me): Promise { 16 | const podRoot = store.any(me, ns.space('storage'), undefined, me.doc()) 17 | if (!podRoot) { 18 | throw new Error('Current user pod root not found!') 19 | } 20 | return podRoot 21 | } 22 | 23 | async function sendInvite (invitee: NamedNode, chatThing: NamedNode) { 24 | await store.fetcher.load(invitee.doc()) 25 | const inviteeInbox = store.any(invitee, ns.ldp('inbox'), undefined, invitee.doc()) 26 | if (!inviteeInbox) { 27 | throw new Error(`Invitee inbox not found! ${invitee.value}`) 28 | } 29 | const inviteBody = ` 30 | <> a ; 31 | ${ns.rdf('seeAlso')} <${chatThing.value}> . 32 | ` 33 | 34 | const inviteResponse = await store.fetcher.webOperation('POST', inviteeInbox.value, { 35 | data: inviteBody, 36 | contentType: 'text/turtle' 37 | }) 38 | const locationStr = inviteResponse.headers.get('location') 39 | if (!locationStr) { 40 | throw new Error(`Invite sending returned a ${inviteResponse.status}`) 41 | } 42 | } 43 | 44 | function determineChatContainer (invitee, podRoot) { 45 | // Create chat 46 | // See https://gitter.im/solid/chat-app?at=5f3c800f855be416a23ae74a 47 | const chatContainerStr = new URL(`IndividualChats/${new URL(invitee.value).host}/`, podRoot.value).toString() 48 | return new NamedNode(chatContainerStr) 49 | } 50 | 51 | async function createChatThing (chatContainer, me) { 52 | const created = await longChatPane.mintNew({ 53 | session: { 54 | store 55 | } 56 | }, 57 | { 58 | me, 59 | newBase: chatContainer.value 60 | }) 61 | return created.newInstance 62 | } 63 | 64 | async function setAcl(chatContainer, me, invitee) { 65 | // Some servers don't present a Link http response header 66 | // if the container doesn't exist yet, so refetch the container 67 | // now that it has been created: 68 | await store.fetcher.load(chatContainer) 69 | 70 | // FIXME: check the Why value on this quad: 71 | const chatAclDoc = store.any(chatContainer, new NamedNode('http://www.iana.org/assignments/link-relations/acl')) 72 | if (!chatAclDoc) { 73 | throw new Error('Chat ACL doc not found!') 74 | } 75 | 76 | const aclBody = ` 77 | @prefix acl: . 78 | <#owner> 79 | a acl:Authorization; 80 | acl:agent <${me.value}>; 81 | acl:accessTo <.>; 82 | acl:default <.>; 83 | acl:mode 84 | acl:Read, acl:Write, acl:Control. 85 | <#invitee> 86 | a acl:Authorization; 87 | acl:agent <${invitee.value}>; 88 | acl:accessTo <.>; 89 | acl:default <.>; 90 | acl:mode 91 | acl:Read, acl:Append. 92 | ` 93 | const aclResponse = await store.fetcher.webOperation('PUT', chatAclDoc.value, { 94 | data: aclBody, 95 | contentType: 'text/turtle' 96 | }) 97 | } 98 | async function addToPrivateTypeIndex(chatThing, me) { 99 | // Add to private type index 100 | const privateTypeIndex = store.any(me, ns.solid('privateTypeIndex')) as NamedNode | null 101 | if (!privateTypeIndex) { 102 | throw new Error('Private type index not found!') 103 | } 104 | await store.fetcher.load(privateTypeIndex) 105 | const reg = widgets.newThing(privateTypeIndex) 106 | const ins = [ 107 | st(reg, ns.rdf('type'), ns.solid('TypeRegistration'), privateTypeIndex.doc()), 108 | st(reg, ns.solid('forClass'), ns.meeting('LongChat'), privateTypeIndex.doc()), 109 | st(reg, ns.solid('instance'), chatThing, privateTypeIndex.doc()) 110 | ] 111 | await new Promise((resolve, reject) => { 112 | store.updater.update([], ins, function (_uri, ok, errm) { 113 | if (!ok) { 114 | reject(new Error(errm)) 115 | } else { 116 | resolve() 117 | } 118 | }) 119 | }) 120 | } 121 | 122 | export async function findChat (invitee: NamedNode) { 123 | const me = await getMe() 124 | const podRoot = await getPodRoot(me) 125 | const chatContainer = determineChatContainer(invitee, podRoot) 126 | let exists = true 127 | try { 128 | await store.fetcher.load(new NamedNode(chatContainer.value + longChatPane.CHAT_LOCATION_IN_CONTAINER)) 129 | } catch (e) { 130 | exists = false 131 | } 132 | return { me, chatContainer, exists} 133 | } 134 | 135 | export async function getChat (invitee: NamedNode, createIfMissing = true): Promise { 136 | const { me, chatContainer, exists } = await findChat (invitee) 137 | if (exists) { 138 | return new NamedNode(chatContainer.value + longChatPane.CHAT_LOCATION_IN_CONTAINER) 139 | } 140 | 141 | if (createIfMissing) { 142 | const chatThing = await createChatThing(chatContainer, me) 143 | await sendInvite(invitee, chatThing) 144 | await setAcl(chatContainer, me, invitee) 145 | await addToPrivateTypeIndex(chatThing, me) 146 | return chatThing 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/longChatPane.js: -------------------------------------------------------------------------------- 1 | /* Long Chat Pane 2 | ** 3 | ** A long chat consists a of a series of chat files saved by date. 4 | */ 5 | import { authn } from 'solid-logic' 6 | import * as UI from 'solid-ui' 7 | import * as $rdf from 'rdflib' 8 | const ns = UI.ns 9 | 10 | const mainClass = ns.meeting('LongChat') // @@ something from SIOC? 11 | 12 | const CHAT_LOCATION_IN_CONTAINER = 'index.ttl#this' 13 | 14 | // const menuIcon = 'noun_897914.svg' 15 | const SPANNER_ICON = 'noun_344563.svg' 16 | // resize: horizontal; min-width: 20em; 17 | const SIDEBAR_COMPONENT_STYLE = UI.style.sidebarComponentStyle || ' padding: 0.5em; width: 100%;' 18 | const SIDEBAR_STYLE = UI.style.sidebarStyle || 'overflow-x: auto; overflow-y: auto; border-radius: 1em; border: 0.1em solid white;' 19 | // was purple border 20 | export const longChatPane = { 21 | CHAT_LOCATION_IN_CONTAINER, 22 | 23 | // noun_704.svg Canoe noun_346319.svg = 1 Chat noun_1689339.svg = three chat 24 | icon: UI.icons.iconBase + 'noun_1689339.svg', 25 | 26 | name: 'long chat', 27 | 28 | label: function (subject, context) { 29 | const kb = context.session.store 30 | if (kb.holds(subject, ns.rdf('type'), ns.meeting('LongChat'))) { 31 | // subject is the object 32 | return 'Chat channnel' 33 | } 34 | if (kb.holds(subject, ns.rdf('type'), ns.sioc('Thread'))) { 35 | // subject is the object 36 | return 'Thread' 37 | } 38 | 39 | // Looks like a message -- might not havre any class declared 40 | if ( 41 | kb.any(subject, ns.sioc('content')) && 42 | kb.any(subject, ns.dct('created')) 43 | ) { 44 | return 'message' 45 | } 46 | return null // Suppress pane otherwise 47 | }, 48 | 49 | mintClass: mainClass, 50 | 51 | mintNew: function (context, newPaneOptions) { 52 | const kb = context.session.store 53 | var updater = kb.updater 54 | if (newPaneOptions.me && !newPaneOptions.me.uri) { 55 | throw new Error('chat mintNew: Invalid userid ' + newPaneOptions.me) 56 | } 57 | 58 | var newInstance = (newPaneOptions.newInstance = 59 | newPaneOptions.newInstance || 60 | kb.sym(newPaneOptions.newBase + CHAT_LOCATION_IN_CONTAINER)) 61 | var newChatDoc = newInstance.doc() 62 | 63 | kb.add(newInstance, ns.rdf('type'), ns.meeting('LongChat'), newChatDoc) 64 | kb.add(newInstance, ns.dc('title'), 'Chat channel', newChatDoc) 65 | kb.add(newInstance, ns.dc('created'), new Date(), newChatDoc) 66 | if (newPaneOptions.me) { 67 | kb.add(newInstance, ns.dc('author'), newPaneOptions.me, newChatDoc) 68 | } 69 | 70 | const aclBody = (me, resource, AppendWrite) => ` 71 | @prefix : <#>. 72 | @prefix acl: . 73 | @prefix foaf: . 74 | @prefix lon: <./${resource}>. 75 | 76 | :ControlReadWrite 77 | a acl:Authorization; 78 | acl:accessTo lon:; 79 | acl:agent <${me.uri}>; 80 | acl:default lon:; 81 | acl:mode acl:Control, acl:Read, acl:Write. 82 | :Read 83 | a acl:Authorization; 84 | acl:accessTo lon:; 85 | acl:agentClass foaf:Agent; 86 | acl:default lon:; 87 | acl:mode acl:Read. 88 | :Read${AppendWrite} 89 | a acl:Authorization; 90 | acl:accessTo lon:; 91 | acl:agentClass acl:AuthenticatedAgent; 92 | acl:default lon:; 93 | acl:mode acl:Read, acl:${AppendWrite}.` 94 | 95 | return new Promise(function (resolve, reject) { 96 | updater.put( 97 | newChatDoc, 98 | kb.statementsMatching(undefined, undefined, undefined, newChatDoc), 99 | 'text/turtle', 100 | function (uri2, ok, message) { 101 | if (ok) { 102 | resolve(newPaneOptions) 103 | } else { 104 | reject( 105 | new Error( 106 | 'FAILED to save new chat channel at: ' + uri2 + ' : ' + message 107 | ) 108 | ) 109 | } 110 | } 111 | ) 112 | // newChat container authenticated users Append only 113 | .then((result) => { 114 | return new Promise((resolve, reject) => { 115 | if (newPaneOptions.me) { 116 | kb.fetcher.webOperation('PUT', newPaneOptions.newBase + '.acl', { 117 | data: aclBody(newPaneOptions.me, '', 'Append'), 118 | contentType: 'text/turtle' 119 | }) 120 | kb.fetcher.webOperation('PUT', newPaneOptions.newBase + 'index.ttl.acl', { 121 | data: aclBody(newPaneOptions.me, 'index.ttl', 'Write'), 122 | contentType: 'text/turtle' 123 | }) 124 | } 125 | resolve(newPaneOptions) 126 | }) 127 | }) 128 | }) 129 | }, 130 | 131 | render: function (subject, context, paneOptions) { 132 | const dom = context.dom 133 | const kb = context.session.store 134 | 135 | /* Preferences 136 | ** 137 | ** Things like whether to color text by author webid, to expand image URLs inline, 138 | ** expanded inline image height. ... 139 | ** In general, preferences can be set per user, per user/app combo, per instance, 140 | ** and per instance/user combo. Per instance? not sure about unless it is valuable 141 | ** for everyone to be seeing the same thing. 142 | */ 143 | // const DCT = $rdf.Namespace('http://purl.org/dc/terms/') 144 | const preferencesFormText = ` 145 | @prefix rdf: . 146 | @prefix solid: . 147 | @prefix ui: . 148 | @prefix : <#>. 149 | 150 | :this 151 | "Chat preferences" ; 152 | a ui:Form ; 153 | ui:parts ( :colorizeByAuthor :expandImagesInline :newestFirst :inlineImageHeightEms 154 | :shiftEnterSendsMessage :authorDateOnLeft :showDeletedMessages). 155 | 156 | :colorizeByAuthor a ui:TristateField; ui:property solid:colorizeByAuthor; 157 | ui:label "Color user input by user". 158 | 159 | :expandImagesInline a ui:TristateField; ui:property solid:expandImagesInline; 160 | ui:label "Expand image URLs inline". 161 | 162 | :newestFirst a ui:TristateField; ui:property solid:newestFirst; 163 | ui:label "Newest messages at the top". 164 | 165 | :inlineImageHeightEms a ui:IntegerField; ui:property solid:inlineImageHeightEms; 166 | ui:label "Inline image height (lines)". 167 | 168 | :shiftEnterSendsMessage a ui:TristateField; ui:property solid:shiftEnterSendsMessage; 169 | ui:label "Shift-Enter sends message". 170 | 171 | :authorDateOnLeft a ui:TristateField; ui:property solid:authorDateOnLeft; 172 | ui:label "Author & date of message on left". 173 | 174 | :showDeletedMessages a ui:TristateField; ui:property solid:showDeletedMessages; 175 | ui:label "Show placeholders for deleted messages". 176 | ` 177 | const preferencesForm = kb.sym( 178 | 'https://solid.github.io/solid-panes/longCharPane/preferencesForm.ttl#this' 179 | ) 180 | const preferencesFormDoc = preferencesForm.doc() 181 | if (!kb.holds(undefined, undefined, undefined, preferencesFormDoc)) { 182 | // If not loaded already 183 | $rdf.parse(preferencesFormText, kb, preferencesFormDoc.uri, 'text/turtle') // Load form directly 184 | } 185 | const preferenceProperties = kb 186 | .statementsMatching(null, ns.ui.property, null, preferencesFormDoc) 187 | .map(st => st.object) 188 | 189 | // Preferences Menu 190 | // 191 | // Build a menu a the side (@@ reactive: on top?) 192 | 193 | async function renderPreferencesSidebar (context) { 194 | // const noun = 'chat room' 195 | const { dom, noun } = context 196 | const preferencesArea = dom.createElement('div') 197 | preferencesArea.appendChild(panelCloseButton(preferencesArea)) 198 | // @@ style below fix .. just make it onviious while testing 199 | preferencesArea.style = SIDEBAR_COMPONENT_STYLE 200 | preferencesArea.style.minWidth = '25em' // bit bigger 201 | preferencesArea.style.maxHeight = triptychHeight 202 | const menuTable = preferencesArea.appendChild(dom.createElement('table')) 203 | const registrationArea = menuTable.appendChild(dom.createElement('tr')) 204 | const statusArea = menuTable.appendChild(dom.createElement('tr')) 205 | 206 | var me = authn.currentUser() 207 | if (me) { 208 | await UI.login.registrationControl( 209 | { noun, me, statusArea, dom, div: registrationArea }, 210 | chatChannel, 211 | mainClass 212 | ) 213 | console.log('Registration control finsished.') 214 | preferencesArea.appendChild( 215 | UI.preferences.renderPreferencesForm( 216 | chatChannel, 217 | mainClass, 218 | preferencesForm, 219 | { 220 | noun, 221 | me, 222 | statusArea, 223 | div: preferencesArea, 224 | dom, 225 | kb 226 | } 227 | ) 228 | ) 229 | } 230 | return preferencesArea 231 | } 232 | 233 | // @@ Split out into solid-ui 234 | 235 | function panelCloseButton (panel) { 236 | function removePanel () { 237 | panel.parentNode.removeChild(panel) 238 | } 239 | const button = 240 | UI.widgets.button(context.dom, UI.icons.iconBase + 'noun_1180156.svg', 'close', removePanel) 241 | button.style.float = 'right' 242 | button.style.margin = '0.7em' 243 | delete button.style.backgroundColor // do not want white 244 | return button 245 | } 246 | async function preferencesButtonPressed (_event) { 247 | if (!preferencesArea) { 248 | // Expand 249 | preferencesArea = await renderPreferencesSidebar({ dom, noun: 'chat room' }) 250 | } 251 | if (paneRight.contains(preferencesArea)) { 252 | // Close menu (hide or delete??) 253 | preferencesArea.parentNode.removeChild(preferencesArea) 254 | preferencesArea = null 255 | } else { 256 | paneRight.appendChild(preferencesArea) 257 | } 258 | } // preferencesButtonPressed 259 | 260 | // All my chats 261 | // 262 | /* Build a other chats list drawer the side 263 | */ 264 | 265 | function renderCreationControl (refreshTarget, noun) { 266 | var creationDiv = dom.createElement('div') 267 | var me = authn.currentUser() 268 | var creationContext = { 269 | // folder: subject, 270 | div: creationDiv, 271 | dom: dom, 272 | noun: noun, 273 | statusArea: creationDiv, 274 | me: me, 275 | refreshTarget: refreshTarget 276 | } 277 | const chatPane = context.session.paneRegistry.byName('chat') 278 | const relevantPanes = [chatPane] 279 | UI.create.newThingUI(creationContext, context, relevantPanes) // Have to pass panes down newUI 280 | return creationDiv 281 | } 282 | 283 | async function renderInstances (theClass, noun) { 284 | const instancesDiv = dom.createElement('div') 285 | var context = { dom, div: instancesDiv, noun: noun } 286 | await UI.login.registrationList(context, { public: true, private: true, type: theClass }) 287 | instancesDiv.appendChild(renderCreationControl(instancesDiv, noun)) 288 | return instancesDiv 289 | } 290 | 291 | var otherChatsArea = null 292 | async function otherChatsHandler (_event) { 293 | if (!otherChatsArea) { // Lazy build when needed 294 | // Expand 295 | otherChatsArea = dom.createElement('div') 296 | otherChatsArea.style = SIDEBAR_COMPONENT_STYLE 297 | otherChatsArea.style.maxHeight = triptychHeight 298 | otherChatsArea.appendChild(panelCloseButton(otherChatsArea)) 299 | 300 | otherChatsArea.appendChild(await renderInstances(ns.meeting('LongChat'), 'chat')) 301 | } 302 | // Toggle visibility with button clicks 303 | if (paneLeft.contains(otherChatsArea)) { 304 | otherChatsArea.parentNode.removeChild(otherChatsArea) 305 | } else { 306 | paneLeft.appendChild(otherChatsArea) 307 | } 308 | } // otherChatsHandler 309 | 310 | // People in the chat 311 | // 312 | /* Build a participants list drawer the side 313 | */ 314 | var participantsArea 315 | function participantsHandler (_event) { 316 | if (!participantsArea) { 317 | // Expand 318 | participantsArea = dom.createElement('div') 319 | participantsArea.style = SIDEBAR_COMPONENT_STYLE 320 | participantsArea.style.maxHeight = triptychHeight 321 | participantsArea.appendChild(panelCloseButton(participantsArea)) 322 | 323 | // Record my participation and display participants 324 | var me = authn.currentUser() 325 | if (!me) alert('Should be logeed in for partipants panel') 326 | UI.pad.manageParticipation( 327 | dom, 328 | participantsArea, 329 | chatChannel.doc(), 330 | chatChannel, 331 | me, 332 | {} 333 | ) 334 | } 335 | // Toggle appearance in sidebar with clicks 336 | // Note also it can remove itself using the X button 337 | if (paneLeft.contains(participantsArea)) { 338 | // Close participants (hide or delete??) 339 | participantsArea.parentNode.removeChild(participantsArea) 340 | participantsArea = null 341 | } else { 342 | paneLeft.appendChild(participantsArea) 343 | } 344 | } // participantsHandler 345 | 346 | var chatChannel = subject 347 | var selectedMessage = null 348 | var thread = null 349 | if (kb.holds(subject, ns.rdf('type'), ns.meeting('LongChat'))) { 350 | // subject is the chatChannel 351 | console.log('@@@ Chat channnel') 352 | 353 | // Looks like a message -- might not havre any class declared 354 | } else if (kb.holds(subject, ns.rdf('type'), ns.sioc('Thread'))) { 355 | // subject is the chatChannel 356 | console.log('Thread is subject ' + subject.uri) 357 | thread = subject 358 | const rootMessage = kb.the(null, ns.sioc('has_reply'), thread, thread.doc()) 359 | if (!rootMessage) throw new Error('Thread has no root message ' + thread) 360 | chatChannel = kb.any(null, ns.wf('message'), rootMessage) 361 | if (!chatChannel) throw new Error('Thread root has no link to chatChannel') 362 | } else if ( // Looks like a message -- might not havre any class declared 363 | kb.any(subject, ns.sioc('content')) && 364 | kb.any(subject, ns.dct('created')) 365 | ) { 366 | console.log('message is subject ' + subject.uri) 367 | selectedMessage = subject 368 | chatChannel = kb.any(null, ns.wf('message'), selectedMessage) 369 | if (!chatChannel) throw new Error('Message has no link to chatChannel') 370 | } 371 | 372 | var div = dom.createElement('div') 373 | 374 | // Three large columns for particpant, chat, Preferences. formula below just as a note 375 | // const windowHeight = window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight 376 | const triptychHeight = '20cm' // @@ need to be able to set to window! 377 | var triptych = div.appendChild(dom.createElement('table')) 378 | triptych.style.maxHeight = '12"' // Screen max 379 | var paneRow = triptych.appendChild(dom.createElement('tr')) 380 | var paneLeft = paneRow.appendChild(dom.createElement('td')) 381 | var paneMiddle = paneRow.appendChild(dom.createElement('td')) 382 | var paneThread = paneRow.appendChild(dom.createElement('td')) 383 | var paneRight = paneRow.appendChild(dom.createElement('td')) 384 | var paneBottom = triptych.appendChild(dom.createElement('tr')) 385 | paneLeft.style = SIDEBAR_STYLE 386 | paneLeft.style.paddingRight = '1em' 387 | paneThread.style = SIDEBAR_STYLE 388 | paneThread.style.paddingLeft = '1em' 389 | paneRight.style = SIDEBAR_STYLE 390 | paneRight.style.paddingLeft = '1em' 391 | 392 | paneBottom.appendChild(dom.createElement('td')) 393 | const buttonCell = paneBottom.appendChild(dom.createElement('td')) 394 | paneBottom.appendChild(dom.createElement('td')) 395 | 396 | // Button to bring up participants drawer on left 397 | const participantsIcon = 'noun_339237.svg' 398 | var participantsButton = UI.widgets.button( 399 | dom, 400 | UI.icons.iconBase + participantsIcon, 401 | 'participants ...' 402 | ) // wider var 403 | buttonCell.appendChild(participantsButton) 404 | participantsButton.addEventListener('click', participantsHandler) 405 | 406 | // Button to bring up otherChats drawer on left 407 | const otherChatsIcon = 'noun_1689339.svg' // long chat icon -- not ideal for a set of chats @@ 408 | var otherChatsButton = UI.widgets.button( 409 | dom, 410 | UI.icons.iconBase + otherChatsIcon, 411 | 'List of other chats ...' 412 | ) // wider var 413 | buttonCell.appendChild(otherChatsButton) 414 | otherChatsButton.addEventListener('click', otherChatsHandler) 415 | 416 | var preferencesArea = null 417 | const menuButton = UI.widgets.button( 418 | dom, 419 | UI.icons.iconBase + SPANNER_ICON, 420 | 'Setting ...' 421 | ) // wider var 422 | buttonCell.appendChild(menuButton) 423 | menuButton.style.float = 'right' 424 | menuButton.addEventListener('click', preferencesButtonPressed) 425 | 426 | div.setAttribute('class', 'chatPane') 427 | const options = { infinite: true } 428 | const participantsHandlerContext = { noun: 'chat room', div, dom: dom } 429 | participantsHandlerContext.me = authn.currentUser() // If already logged on 430 | 431 | async function showThread(thread, options) { 432 | console.log('@@@@ showThread thread ' + thread) 433 | const newOptions = {} // @@@ inherit 434 | newOptions.thread = thread 435 | newOptions.includeRemoveButton = true 436 | 437 | newOptions.authorDateOnLeft = options.authorDateOnLeft 438 | newOptions.newestFirst = options.newestFirst 439 | 440 | paneThread.innerHTML = '' 441 | console.log('Options for showThread message Area', newOptions) 442 | 443 | const chatControl = await UI.infiniteMessageArea( 444 | dom, 445 | kb, 446 | chatChannel, 447 | newOptions 448 | ) 449 | chatControl.style.resize = 'both' 450 | chatControl.style.overflow = 'auto' 451 | chatControl.style.maxHeight = triptychHeight 452 | paneThread.appendChild(chatControl) 453 | } 454 | 455 | async function buildPane () { 456 | let prefMap 457 | try { 458 | prefMap = await UI.preferences.getPreferencesForClass( 459 | chatChannel, mainClass, preferenceProperties, participantsHandlerContext) 460 | } catch (err) { 461 | UI.widgets.complain(participantsHandlerContext, err) 462 | } 463 | for (const propuri in prefMap) { 464 | options[propuri.split('#')[1]] = prefMap[propuri] 465 | } 466 | if (selectedMessage) { 467 | options.selectedMessage = selectedMessage 468 | } 469 | if (paneOptions.solo) { 470 | // This is the top pane, title, scrollbar etc are ours 471 | options.solo = true 472 | } 473 | if (thread) { // Rendereing a thread as first class object 474 | options.thread = thread 475 | } else { // either show thread *or* allow new threads. Threads don't nest but they could 476 | options.showThread = showThread 477 | } 478 | const chatControl = await UI.infiniteMessageArea( 479 | dom, 480 | kb, 481 | chatChannel, 482 | options 483 | ) 484 | chatControl.style.resize = 'both' 485 | chatControl.style.overflow = 'auto' 486 | chatControl.style.maxHeight = triptychHeight 487 | paneMiddle.appendChild(chatControl) 488 | } 489 | buildPane().then(console.log('async - chat pane built')) 490 | return div 491 | } 492 | } 493 | -------------------------------------------------------------------------------- /src/longChatPane.test.ts: -------------------------------------------------------------------------------- 1 | import { longChatPane } from "./longChatPane"; 2 | 3 | describe("chat test", () => { 4 | it("works", () => { 5 | expect(2==2); 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | export { shortChatPane } from './shortChatPane' 2 | export { longChatPane } from './longChatPane' 3 | export { getChat } from './create' 4 | -------------------------------------------------------------------------------- /src/shortChatPane.js: -------------------------------------------------------------------------------- 1 | /* Chat Pane 2 | ** 3 | ** Plan is to support a finte number of chat graph shapes 4 | ** and investigate the interop between them. 5 | */ 6 | /* global $rdf */ 7 | import { store } from 'solid-logic' 8 | import * as UI from 'solid-ui' 9 | 10 | const ns = UI.ns 11 | 12 | export const shortChatPane = { 13 | icon: UI.icons.iconBase + 'noun_346319.svg', 14 | 15 | name: 'chat', 16 | 17 | /* 18 | * As part of the work on merging the existing chat views (aka panes) into one view 19 | * https://github.com/solid/chat-pane/issues/17 20 | * we want to dis-incentivize the use of Small Chat until we've gotten the work done 21 | * by making it difficult to create new data resources that uses the Small Chat view 22 | * but we still want existing resources to be viewed by the Small Chat view 23 | */ 24 | audience: [ns.solid('PowerUser')], 25 | 26 | /* AN RRSAgent IRC log: 27 | 28 | 29 | a foaf:ChatChannel 30 | foaf:chatEventList 31 | [ rdf:_100 32 | <#T19-10-58> 33 | rdf:_101 34 | <#T19-10-58-1> 35 | rdf:_102 36 | .. 37 | <#T19-28-47-1> 38 | dc:creator 39 | [ a wn:Person; foaf:nick "timbl" ] 40 | dc:date 41 | "2016-03-15T19:28:47Z" 42 | dc:description 43 | "timbl has joined &mit" 44 | a foaf:chatEvent. 45 | 46 | */ 47 | 48 | label: function (subject, context) { 49 | const kb = context.session.store 50 | var n = kb.each(subject, ns.wf('message')).length 51 | if (n > 0) return 'Chat (' + n + ')' // Show how many in hover text 52 | 53 | if (kb.holds(subject, ns.rdf('type'), ns.meeting('Chat'))) { 54 | // subject is the file 55 | return 'Meeting chat' 56 | } 57 | if (kb.holds(undefined, ns.rdf('type'), ns.foaf('ChatChannel'), subject)) { 58 | // subject is the file 59 | return 'IRC log' // contains anything of this type 60 | } 61 | return null // Suppress pane otherwise 62 | }, 63 | 64 | mintClass: ns.meeting('Chat'), 65 | 66 | mintNew: function (context, newPaneOptions) { 67 | // This deprecates the creation of short Chats after 2023-04-03. 68 | // The mintNew function will be removed/commented out in a few months. 69 | if (!confirm('short Chat is deprecated in favor of long Chat.' 70 | + '\nEmbedded chat for comments and existing short Chats will work.' 71 | + '\nYou can report any issues at https://github.com/SolidOS/chat-pane ?' 72 | + '\n\nDo you really want to create a new deprecated short Chat?')) return 73 | const kb = context.session.store 74 | var updater = kb.updater 75 | if (newPaneOptions.me && !newPaneOptions.me.uri) { 76 | throw new Error('chat mintNew: Invalid userid ' + newPaneOptions.me) 77 | } 78 | 79 | var newInstance = (newPaneOptions.newInstance = 80 | newPaneOptions.newInstance || 81 | kb.sym(newPaneOptions.newBase + 'index.ttl#this')) 82 | var newChatDoc = newInstance.doc() 83 | 84 | kb.add(newInstance, ns.rdf('type'), ns.meeting('Chat'), newChatDoc) 85 | kb.add(newInstance, ns.dc('title'), 'Chat', newChatDoc) 86 | kb.add(newInstance, ns.dc('created'), new Date(), newChatDoc) 87 | if (newPaneOptions.me) { 88 | kb.add(newInstance, ns.dc('author'), newPaneOptions.me, newChatDoc) 89 | } 90 | 91 | return new Promise(function (resolve, reject) { 92 | updater.put( 93 | newChatDoc, 94 | kb.statementsMatching(undefined, undefined, undefined, newChatDoc), 95 | 'text/turtle', 96 | function (uri2, ok, message) { 97 | if (ok) { 98 | resolve(newPaneOptions) 99 | } else { 100 | reject( 101 | new Error('FAILED to save new tool at: ' + uri2 + ' : ' + message) 102 | ) 103 | } 104 | } 105 | ) 106 | }) 107 | }, 108 | 109 | render: function (subject, context) { 110 | const kb = context.session.store 111 | const dom = context.dom 112 | var complain = function complain (message, color) { 113 | var pre = dom.createElement('pre') 114 | pre.setAttribute('style', 'background-color: ' + color || '#eed' + ';') 115 | div.appendChild(pre) 116 | pre.appendChild(dom.createTextNode(message)) 117 | } 118 | 119 | var div = dom.createElement('div') 120 | div.setAttribute('class', 'chatPane') 121 | const options = {} // Like newestFirst 122 | var messageStore 123 | if (kb.holds(subject, ns.rdf('type'), ns.meeting('Chat'))) { 124 | // subject may be the file 125 | messageStore = subject.doc() 126 | } else if (kb.any(subject, UI.ns.wf('message'))) { 127 | messageStore = store.any(subject, UI.ns.wf('message')).doc() 128 | } else if ( 129 | kb.holds(undefined, ns.rdf('type'), ns.foaf('ChatChannel'), subject) || 130 | kb.holds(subject, ns.rdf('type'), ns.foaf('ChatChannel')) 131 | ) { 132 | // subject is the file 133 | var ircLogQuery = function () { 134 | var query = new $rdf.Query('IRC log entries') 135 | var v = [] 136 | var vv = ['chan', 'msg', 'date', 'list', 'pred', 'creator', 'content'] 137 | vv.map(function (x) { 138 | query.vars.push((v[x] = $rdf.variable(x))) 139 | }) 140 | query.pat.add(v.chan, ns.foaf('chatEventList'), v.list) // chatEventList 141 | query.pat.add(v.list, v.pred, v.msg) // 142 | query.pat.add(v.msg, ns.dc('date'), v.date) 143 | query.pat.add(v.msg, ns.dc('creator'), v.creator) 144 | query.pat.add(v.msg, ns.dc('description'), v.content) 145 | return query 146 | } 147 | messageStore = subject.doc() 148 | options.query = ircLogQuery() 149 | } else { 150 | complain('Unknown chat type') 151 | } 152 | 153 | // var context = {dom, div} 154 | // UI.authn.logIn(context).then( context => { // The widget itself sees to login 155 | 156 | div.appendChild(UI.messageArea(dom, kb, subject, messageStore, options)) 157 | kb.updater.addDownstreamChangeListener(messageStore, function () { 158 | UI.widgets.refreshTree(div) 159 | }) // Live update 160 | // }) 161 | 162 | return div 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | 2 | const HtmlWebpackPlugin = require('html-webpack-plugin') 3 | const NodePolyfillPlugin = require("node-polyfill-webpack-plugin") 4 | 5 | module.exports = [{ 6 | mode: 'development', 7 | entry: './dev/index.js', 8 | plugins: [ 9 | new HtmlWebpackPlugin({ template: './dev/index.html' }), 10 | new NodePolyfillPlugin() 11 | ], 12 | resolve: { 13 | extensions: ['.mjs', '.js', '.ts'], 14 | fallback: { path: false } 15 | }, 16 | module: { 17 | rules: [ 18 | { 19 | test: /\.(mjs|js|ts)$/, 20 | exclude: /(node_modules|bower_components)/, 21 | use: { 22 | loader: 'babel-loader' 23 | } 24 | } 25 | ] 26 | }, 27 | externals: { 28 | fs: 'null', 29 | 'node-fetch': 'fetch', 30 | 'isomorphic-fetch': 'fetch', 31 | xmldom: 'window', 32 | 'text-encoding': 'TextEncoder', 33 | 'whatwg-url': 'window', 34 | '@trust/webcrypto': 'crypto' 35 | }, 36 | devServer: { 37 | static: './dist' 38 | }, 39 | devtool: 'source-map' 40 | }, 41 | { 42 | mode: 'development', 43 | entry: { 44 | shortChatPane: './src/shortChatPane.js', 45 | longChatPane: './src/longChatPane.js' 46 | }, 47 | resolve: { 48 | fallback: { path: false } 49 | }, 50 | externals: { 51 | fs: 'null', 52 | 'node-fetch': 'fetch', 53 | 'isomorphic-fetch': 'fetch', 54 | xmldom: 'window', 55 | 'text-encoding': 'TextEncoder', 56 | 'whatwg-url': 'window', 57 | '@trust/webcrypto': 'crypto' 58 | }, 59 | devtool: 'source-map' 60 | }] 61 | -------------------------------------------------------------------------------- /webpack.dev.config.js: -------------------------------------------------------------------------------- 1 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 2 | const NodePolyfillPlugin = require("node-polyfill-webpack-plugin"); 3 | 4 | module.exports = [ 5 | { 6 | mode: "development", 7 | entry: ["./dev/index.js"], 8 | plugins: [ 9 | new HtmlWebpackPlugin({ template: "./dev/index.html" }), 10 | new NodePolyfillPlugin() 11 | ], 12 | module: { 13 | rules: [ 14 | { 15 | test: /\.(js|ts)$/, 16 | exclude: /node_modules/, 17 | use: ["babel-loader"], 18 | }, 19 | ], 20 | }, 21 | resolve: { 22 | extensions: ["*", ".js", ".ts"] 23 | }, 24 | 25 | devServer: { 26 | static: './dist' 27 | }, 28 | devtool: "source-map", 29 | }, 30 | ]; 31 | --------------------------------------------------------------------------------