├── app ├── containers │ ├── empty.js │ ├── avatar.js │ ├── customLink.js │ ├── appSettings.js │ ├── dialog.js │ ├── memberList.js │ ├── layout.js │ ├── channelPanel.js │ ├── cabalsList.js │ ├── addCabal.js │ ├── cabalSettings.js │ ├── channelBrowser.js │ ├── profilePanel.js │ ├── messages.js │ ├── mainPanel.js │ ├── write.js │ └── sidebar.js ├── settings.js ├── platform.js ├── updater.js ├── index.js ├── app.js ├── selectors.js ├── styles │ ├── react-contexify.css │ └── darkmode.scss ├── reducer.js └── actions.js ├── .env-sample ├── screenshot.png ├── static ├── images │ ├── dmg-background.tiff │ ├── cabal-desktop-dmg-background.jpg │ ├── cabal-desktop-dmg-background@2x.jpg │ ├── cabal-logo-black.svg │ ├── cabal-logo-white.svg │ ├── icon-status-offline.svg │ ├── icon-status-online.svg │ ├── icon-composermeta.svg │ ├── icon-newchannel.svg │ ├── icon-sidebarmenu.svg │ ├── icon-channelother.svg │ ├── icon-addcabal.svg │ ├── icon-channel.svg │ ├── icon-composerother.svg │ ├── icon-gear.svg │ └── user-icon.svg └── fonts │ └── Noto_Sans_JP │ ├── NotoSansJP-Black.otf │ ├── NotoSansJP-Bold.otf │ ├── NotoSansJP-Light.otf │ ├── NotoSansJP-Thin.otf │ ├── NotoSansJP-Medium.otf │ ├── NotoSansJP-Regular.otf │ └── OFL.txt ├── .gitignore ├── .babelrc ├── .travis.yml ├── scripts └── notarize.js ├── .vscode └── launch.json ├── bin └── build-multi ├── .github └── FUNDING.yml ├── webpack.config.js ├── index.html ├── README.md ├── package.json ├── index.js └── CHANGELOG.md /app/containers/empty.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.env-sample: -------------------------------------------------------------------------------- 1 | APPLEID= 2 | APPLEIDPASS= 3 | ASCPROVIDER= 4 | BUNDLEID= 5 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cabal-club/cabal-desktop/HEAD/screenshot.png -------------------------------------------------------------------------------- /static/images/dmg-background.tiff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cabal-club/cabal-desktop/HEAD/static/images/dmg-background.tiff -------------------------------------------------------------------------------- /static/fonts/Noto_Sans_JP/NotoSansJP-Black.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cabal-club/cabal-desktop/HEAD/static/fonts/Noto_Sans_JP/NotoSansJP-Black.otf -------------------------------------------------------------------------------- /static/fonts/Noto_Sans_JP/NotoSansJP-Bold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cabal-club/cabal-desktop/HEAD/static/fonts/Noto_Sans_JP/NotoSansJP-Bold.otf -------------------------------------------------------------------------------- /static/fonts/Noto_Sans_JP/NotoSansJP-Light.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cabal-club/cabal-desktop/HEAD/static/fonts/Noto_Sans_JP/NotoSansJP-Light.otf -------------------------------------------------------------------------------- /static/fonts/Noto_Sans_JP/NotoSansJP-Thin.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cabal-club/cabal-desktop/HEAD/static/fonts/Noto_Sans_JP/NotoSansJP-Thin.otf -------------------------------------------------------------------------------- /static/images/cabal-desktop-dmg-background.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cabal-club/cabal-desktop/HEAD/static/images/cabal-desktop-dmg-background.jpg -------------------------------------------------------------------------------- /static/fonts/Noto_Sans_JP/NotoSansJP-Medium.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cabal-club/cabal-desktop/HEAD/static/fonts/Noto_Sans_JP/NotoSansJP-Medium.otf -------------------------------------------------------------------------------- /static/fonts/Noto_Sans_JP/NotoSansJP-Regular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cabal-club/cabal-desktop/HEAD/static/fonts/Noto_Sans_JP/NotoSansJP-Regular.otf -------------------------------------------------------------------------------- /static/images/cabal-desktop-dmg-background@2x.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cabal-club/cabal-desktop/HEAD/static/images/cabal-desktop-dmg-background@2x.jpg -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | static/build.js 3 | *.sw[op] 4 | .DS* 5 | dist 6 | .idea 7 | yarn-error.log 8 | .env 9 | .nvmrc 10 | *.code-workspace 11 | 12 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/env", 4 | "@babel/react" 5 | ], 6 | "plugins": ["@babel/plugin-transform-runtime", "@babel/plugin-proposal-optional-chaining"] 7 | } -------------------------------------------------------------------------------- /static/images/cabal-logo-black.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /static/images/cabal-logo-white.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/settings.js: -------------------------------------------------------------------------------- 1 | const Store = require('electron-store') 2 | 3 | const store = new Store({ name: 'cabal-desktop-settings' }) 4 | const store_defaults = { 5 | 'auto-update': true 6 | } 7 | 8 | for (var key in store_defaults) { 9 | if (store.get(key) === undefined) store.set(key, store_defaults[key]) 10 | } 11 | 12 | module.exports = store 13 | -------------------------------------------------------------------------------- /app/platform.js: -------------------------------------------------------------------------------- 1 | function isMac () { 2 | if (typeof window === 'undefined'){ 3 | const process = require('process') 4 | return process.platform === 'darwin' 5 | } else { 6 | return window.navigator.platform.toLowerCase().indexOf('mac') >= 0 7 | } 8 | } 9 | 10 | var platform = { 11 | mac: isMac() 12 | } 13 | 14 | module.exports = platform -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '10' 4 | cache: yarn 5 | 6 | os: 7 | - osx 8 | - linux 9 | - windows 10 | 11 | install: 12 | - yarn install 13 | - yarn run build 14 | - yarn run dist 15 | 16 | deploy: 17 | draft: true 18 | edge: true 19 | file_glob: true 20 | overwrite: true 21 | provider: releases 22 | on: 23 | tags: true 24 | all_branches: true 25 | -------------------------------------------------------------------------------- /app/containers/avatar.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import Identicon from 'react-blockies' 3 | 4 | export default class Avatar extends Component { 5 | render () { 6 | return ( 7 | 8 | 13 | 14 | ) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /app/updater.js: -------------------------------------------------------------------------------- 1 | const { autoUpdater } = require('electron-updater') 2 | 3 | class AutoUpdater { 4 | constructor () { 5 | this.interval = false 6 | } 7 | 8 | start () { 9 | const FOUR_HOURS = 60 * 60 * 1000 10 | try { 11 | this.interval = setInterval(() => autoUpdater.checkForUpdatesAndNotify(), FOUR_HOURS) 12 | autoUpdater.checkForUpdatesAndNotify() 13 | } catch (err) { 14 | // If offline, the auto updater will throw an error. 15 | console.error(err) 16 | } 17 | } 18 | 19 | stop () { 20 | clearInterval(this.interval) 21 | } 22 | } 23 | 24 | module.exports = AutoUpdater 25 | -------------------------------------------------------------------------------- /scripts/notarize.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config() 2 | const { notarize } = require('electron-notarize') 3 | 4 | exports.default = async function notarizing (context) { 5 | const { electronPlatformName, appOutDir } = context 6 | if (electronPlatformName !== 'darwin') { 7 | return 8 | } 9 | 10 | const appName = context.packager.appInfo.productFilename 11 | 12 | return await notarize({ 13 | ascProvider: process.env.ASCPROVIDER, 14 | appBundleId: process.env.BUNDLEID, 15 | appPath: `${appOutDir}/${appName}.app`, 16 | appleId: process.env.APPLEID, 17 | appleIdPassword: process.env.APPLEIDPASS 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug Main Process", 6 | "type": "node", 7 | "request": "launch", 8 | "cwd": "${workspaceFolder}", 9 | "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron", 10 | "windows": { 11 | "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron.cmd" 12 | }, 13 | "args" : ["."], 14 | "outputCapture": "std" 15 | }, 16 | { 17 | "type": "chrome", 18 | "request": "attach", 19 | "name": "Attach to Render Process", 20 | "port": 9222, 21 | "webRoot": "${workspaceRoot}/html" 22 | } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /bin/build-multi: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | docker run --rm -ti \ 4 | --env-file <(env | grep -iE 'DEBUG|NODE_|ELECTRON_|YARN_|NPM_|CI|CIRCLE|TRAVIS_TAG|TRAVIS|TRAVIS_REPO_|TRAVIS_BUILD_|TRAVIS_BRANCH|TRAVIS_PULL_REQUEST_|APPVEYOR_|CSC_|GH_|GITHUB_|BT_|AWS_|STRIP|BUILD_') \ 5 | --env ELECTRON_CACHE="/root/.cache/electron" \ 6 | --env ELECTRON_BUILDER_CACHE="/root/.cache/electron-builder" \ 7 | -v ${PWD}:/project \ 8 | -v ${PWD##*/}-node-modules:/project/node_modules \ 9 | -v ~/.cache/electron:/root/.cache/electron \ 10 | -v ~/.cache/electron-builder:/root/.cache/electron-builder \ 11 | electronuserland/builder:wine \ 12 | /bin/bash -c "uname -a && yarn install && yarn run build && yarn run dist:multi" 13 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: cabal-club 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /static/images/icon-status-offline.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 9 | icon-status-online 10 | Created with Sketch. 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /static/images/icon-status-online.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 9 | icon-status-online 10 | Created with Sketch. 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /static/images/icon-composermeta.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | icon-composermeta 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /static/images/icon-newchannel.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | icon-newchannel 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render } from 'react-dom' 3 | import { ipcRenderer } from 'electron' 4 | import { Provider } from 'react-redux' 5 | import { createStore, applyMiddleware, compose } from 'redux' 6 | import reducer from './reducer' 7 | import App from './app' 8 | import logger from 'redux-logger' 9 | import thunk from 'redux-thunk' 10 | 11 | // Disable debug console.log messages coming from dependencies 12 | window.localStorage.removeItem('debug') 13 | 14 | const middlewares = [thunk] 15 | 16 | if (process.env.ENABLE_APP_LOG) { 17 | middlewares.push(logger) 18 | } 19 | 20 | const store = createStore( 21 | reducer, 22 | compose(applyMiddleware(...middlewares)) 23 | ) 24 | 25 | ipcRenderer.on('darkMode', (event, darkMode) => { 26 | store.dispatch({ 27 | type: 'CHANGE_DARK_MODE', 28 | darkMode 29 | }) 30 | }) 31 | 32 | render( 33 | 34 | 35 | , 36 | document.querySelector('#root') 37 | ) 38 | -------------------------------------------------------------------------------- /static/images/icon-sidebarmenu.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Icon-sidebarmenu 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /static/images/icon-channelother.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | icon-channelother 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /app/containers/customLink.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | 3 | const CustomLink = ({ href = '', children, ...props }) => { 4 | const [isTooltipDisplayed, setIsTooltipDisplayed] = useState(false) 5 | const [coordinates, setCoordinates] = useState({}) 6 | 7 | const onMouseEnter = e => { 8 | const rect = e.target.getBoundingClientRect() 9 | setCoordinates({ 10 | top: rect.y + window.scrollY, 11 | left: rect.x + rect.width / 2 12 | }) 13 | 14 | setIsTooltipDisplayed(true) 15 | } 16 | const onMouseLeave = () => setIsTooltipDisplayed(false) 17 | 18 | const tooltipText = href.length > 500 ? `${href.substring(0, 500)}...` : href 19 | 20 | return ( 21 | 28 | {children} 29 | {isTooltipDisplayed && 30 | 31 | {tooltipText} 32 | } 33 | 34 | ) 35 | } 36 | 37 | export default CustomLink 38 | -------------------------------------------------------------------------------- /static/images/icon-addcabal.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Path Copy 2 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /static/images/icon-channel.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | icon-channel 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const Dotenv = require('dotenv-webpack') 2 | const nodeExternals = require('webpack-node-externals') 3 | const path = require('path') 4 | 5 | module.exports = { 6 | entry: './app/index.js', 7 | mode: 'production', 8 | target: 'electron-renderer', 9 | watch: process.env.NODE_ENV === 'development', 10 | externals: [nodeExternals()], 11 | output: { 12 | path: path.join(__dirname, 'static'), 13 | filename: 'build.js', 14 | libraryTarget: 'commonjs2' 15 | }, 16 | devtool: 'eval', 17 | node: { 18 | __dirname: true 19 | }, 20 | module: { 21 | rules: [ 22 | { 23 | test: /\.js$/, 24 | include: path.resolve(__dirname, 'app'), 25 | loader: 'babel-loader', 26 | query: { 27 | presets: ['@babel/react'], 28 | plugins: [ 29 | '@babel/plugin-proposal-object-rest-spread' 30 | ] 31 | } 32 | }, 33 | { 34 | test: /\.scss$/, 35 | use: ['style-loader', 'css-loader', 'sass-loader'] 36 | }, 37 | { 38 | test: /\.css$/, 39 | use: ['style-loader', 'css-loader'] 40 | } 41 | ] 42 | }, 43 | plugins: [ 44 | new Dotenv() 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /app/containers/appSettings.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { connect } from 'react-redux' 3 | // import { addCabal } from '../actions' 4 | 5 | import { version } from '../../package.json' 6 | 7 | const mapStateToProps = state => state 8 | 9 | const mapDispatchToProps = dispatch => ({ 10 | hide: () => dispatch({ type: 'CHANGE_SCREEN', screen: 'main' }) 11 | }) 12 | 13 | class AppSettingsScreen extends Component { 14 | onClickClose () { 15 | this.props.hide() 16 | } 17 | 18 | render () { 19 | return ( 20 |
21 |
22 | 23 |

Cabal Desktop Settings

24 |
25 | 26 |
27 | Nothing to set at the moment. 🤷‍♀️ 28 |
29 | 30 |
31 | Version {version} 32 |
33 |
34 | ) 35 | } 36 | } 37 | 38 | const AppSettingsContainer = connect(mapStateToProps, mapDispatchToProps)(AppSettingsScreen) 39 | 40 | export default AppSettingsContainer 41 | -------------------------------------------------------------------------------- /app/app.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { connect } from 'react-redux' 3 | 4 | import AddCabalContainer from './containers/addCabal' 5 | import AppSettingsContainer from './containers/appSettings' 6 | import Layout from './containers/layout' 7 | import { loadFromDisk } from './actions' 8 | 9 | import './styles/react-contexify.css' 10 | import './styles/style.scss' 11 | import './styles/darkmode.scss' 12 | 13 | const mapStateToProps = state => ({ 14 | screen: state.screen 15 | }) 16 | 17 | const mapDispatchToProps = dispatch => ({ 18 | loadFromDisk: () => dispatch(loadFromDisk()) 19 | }) 20 | 21 | export class AppScreen extends Component { 22 | constructor (props) { 23 | super(props) 24 | props.loadFromDisk() 25 | } 26 | 27 | render () { 28 | const { screen } = this.props 29 | let Container = Layout 30 | if (screen === 'addCabal') { 31 | Container = AddCabalContainer 32 | } else if (screen === 'appSettings') { 33 | Container = AppSettingsContainer 34 | } 35 | return ( 36 | <> 37 | 38 | 39 | ) 40 | } 41 | } 42 | 43 | const App = connect(mapStateToProps, mapDispatchToProps)(AppScreen) 44 | 45 | export default App 46 | -------------------------------------------------------------------------------- /static/images/icon-composerother.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | icon-composerother 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /app/containers/dialog.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { 3 | confirmDeleteCabal, 4 | cancelDeleteCabal 5 | } from '../actions' 6 | import { connect } from 'react-redux' 7 | 8 | const Confirm = ({ addr, onConfirm, onExit }) => ( 9 |
13 |
14 |

Leave Cabal

15 |

16 | Are you sure you want to leave this cabal? 17 |
18 | This can’t be undone. 19 |

20 |

21 | 27 | 30 |

31 |
37 |
38 | 39 | ) 40 | 41 | export const ConfirmContainer = connect( 42 | state => ({ 43 | cabal: state.dialogs.delete.cabal 44 | }), 45 | dispatch => ({ 46 | onConfirm: addr => dispatch(confirmDeleteCabal(addr)), 47 | onExit: () => dispatch(cancelDeleteCabal()) 48 | }) 49 | )(Confirm) 50 | -------------------------------------------------------------------------------- /static/images/icon-gear.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 42 | 43 | 44 |
45 |
46 |
47 | 48 |
Initializing...
49 |
50 |
51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /app/containers/memberList.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { connect } from 'react-redux' 3 | import { currentChannelMembersSelector } from '../selectors' 4 | 5 | import { 6 | showProfilePanel 7 | } from '../actions' 8 | import Avatar from './avatar' 9 | 10 | const mapDispatchToProps = dispatch => ({ 11 | showProfilePanel: ({ addr, userKey }) => dispatch(showProfilePanel({ addr, userKey })) 12 | }) 13 | 14 | function MemberList (props) { 15 | function onClickUser (user) { 16 | props.showProfilePanel({ 17 | addr: props.addr, 18 | userKey: user.key 19 | }) 20 | } 21 | 22 | return ( 23 | <> 24 | {props.members && props.members.map((user) => 25 |
onClickUser(user)} title={user.key}> 26 |
27 | {!!user.online && 28 | Online} 29 | {!user.online && 30 | Offline} 31 |
32 | 33 | {!!user.online && 34 |
{user.name || user.key.substring(0, 6)}
} 35 | {!user.online && 36 |
{user.name || user.key.substring(0, 6)}
} 37 |
38 |
39 | )} 40 | 41 | ) 42 | } 43 | 44 | export default connect(state => { 45 | return { 46 | members: currentChannelMembersSelector(state) 47 | } 48 | }, mapDispatchToProps)(MemberList) 49 | -------------------------------------------------------------------------------- /static/images/user-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 19 | 20 | 22 | image/svg+xml 23 | 25 | 26 | 27 | 28 | 29 | 31 | 51 | 54 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /app/containers/layout.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { connect } from 'react-redux' 3 | import { 4 | changeScreen, 5 | viewCabal 6 | } from '../actions' 7 | import CabalsList from './cabalsList' 8 | import ChannelPanel from './channelPanel' 9 | import MainPanel from './mainPanel' 10 | import ProfilePanel from './profilePanel' 11 | import Sidebar from './sidebar' 12 | import { cabalSettingsSelector, isCabalsInitializedSelector } from '../selectors' 13 | 14 | 15 | 16 | function LayoutScreen(props) { 17 | const [showMemberList, setShowMemberList] = useState(false) 18 | 19 | const toggleMemberList = () => { 20 | setShowMemberList( 21 | !showMemberList 22 | ) 23 | } 24 | 25 | if (!props.cabalInitialized) { 26 | return ( 27 |
28 |
29 | 30 |
Loading hypercores and swarming...
31 |
32 | ) 33 | } 34 | 35 | return ( 36 |
37 | 38 | 39 | 40 | {props.channelPanelVisible && } 41 | {props.profilePanelVisible && } 42 |
43 | ) 44 | } 45 | 46 | const mapStateToProps = state => { 47 | return { 48 | addr: state.currentCabal, 49 | cabalInitialized: isCabalsInitializedSelector(state), 50 | channelPanelVisible: state.channelPanelVisible[state.currentCabal], 51 | profilePanelVisible: state.profilePanelVisible[state.currentCabal], 52 | profilePanelUser: state.profilePanelUser[state.currentCabal], 53 | settings: cabalSettingsSelector(state), 54 | darkMode: state?.globalSettings?.darkMode || false 55 | } 56 | } 57 | 58 | const mapDispatchToProps = dispatch => ({ 59 | changeScreen: ({ screen, addr }) => dispatch(changeScreen({ screen, addr })), 60 | viewCabal: ({ addr }) => dispatch(viewCabal({ addr })) 61 | }) 62 | 63 | const Layout = connect(mapStateToProps, mapDispatchToProps)(LayoutScreen) 64 | 65 | export default Layout 66 | -------------------------------------------------------------------------------- /app/selectors.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from '@reduxjs/toolkit' 2 | import { prop } from 'lodash/fp' 3 | export const currentCabalSelector = createSelector( 4 | state => state.currentCabal, 5 | state => state.cabals || {}, 6 | (currentCabal, cabals) => cabals[currentCabal] || {} 7 | ) 8 | 9 | function sortUsers(users = []) { 10 | if (Array.isArray(users)) { 11 | return users.sort((a, b) => { 12 | if (a.isHidden() && !b.isHidden()) return 1 13 | if (b.isHidden() && !a.isHidden()) return -1 14 | if (a.online && !b.online) return -1 15 | if (b.online && !a.online) return 1 16 | if (a.isAdmin() && !b.isAdmin()) return -1 17 | if (b.isAdmin() && !a.isAdmin()) return 1 18 | if (a.isModerator() && !b.isModerator()) return -1 19 | if (b.isModerator() && !a.isModerator()) return 1 20 | if (a.name && !b.name) return -1 21 | if (b.name && !a.name) return 1 22 | if (a.name && b.name) return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1 23 | return a.key < b.key ? -1 : 1 24 | }) 25 | } 26 | } 27 | 28 | export const currentChannelMembersSelector = createSelector( 29 | currentCabalSelector, 30 | cabal => sortUsers(cabal.channelMembers) 31 | ) 32 | 33 | export const currentChannelMemberCountSelector = createSelector( 34 | currentChannelMembersSelector, 35 | (members = []) => members.length 36 | ) 37 | 38 | // check if all cabals are initialized and current one is set 39 | export const isCabalsInitializedSelector = createSelector( 40 | state => state.currentCabal, 41 | state => state.cabals || {}, 42 | (current, cabals) => { 43 | const cabalsInitialized = Object.values(cabals).every(prop('initialized')) 44 | return cabalsInitialized && !!current 45 | } 46 | ) 47 | 48 | 49 | // current cabals settings 50 | export const cabalSettingsSelector = createSelector( 51 | state => state?.currentCabal || "", 52 | state => state.cabalSettings, 53 | (addr, settings) => settings[addr] || {} 54 | ) 55 | 56 | // messages of current cabal 57 | export const currentChannelMessagesSelector = createSelector( 58 | currentCabalSelector, 59 | cabal => cabal?.messages || [] 60 | ) 61 | 62 | 63 | // select current channel 64 | export const currentChannelSelector = createSelector( 65 | currentCabalSelector, 66 | cabal => cabal?.channel 67 | ) 68 | -------------------------------------------------------------------------------- /app/containers/channelPanel.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { connect } from 'react-redux' 3 | 4 | import { 5 | confirmArchiveChannel, 6 | hideChannelPanel, 7 | leaveChannel 8 | } from '../actions' 9 | import MemberList from './memberList' 10 | 11 | const mapStateToProps = state => ({ 12 | addr: state.currentCabal, 13 | currentChannel: state.cabals[state.currentCabal].channel || '' 14 | }) 15 | 16 | const mapDispatchToProps = dispatch => ({ 17 | confirmArchiveChannel: ({ addr, channel }) => dispatch(confirmArchiveChannel({ addr, channel })), 18 | hideChannelPanel: ({ addr }) => dispatch(hideChannelPanel({ addr })), 19 | leaveChannel: ({ addr, channel }) => dispatch(leaveChannel({ addr, channel })) 20 | }) 21 | 22 | function ChannelPanel ({ addr, confirmArchiveChannel, currentChannel, hideChannelPanel, leaveChannel }) { 23 | function onClickLeaveChannel () { 24 | leaveChannel({ 25 | addr, 26 | channel: currentChannel 27 | }) 28 | } 29 | 30 | function onClickArchiveChannel () { 31 | confirmArchiveChannel({ 32 | addr, 33 | channel: currentChannel 34 | }) 35 | } 36 | 37 | const canLeave = currentChannel !== '!status' && !!currentChannel 38 | const hasMembers = currentChannel !== '!status' 39 | 40 | return ( 41 |
42 |
43 | Channel Details 44 | hideChannelPanel({ addr })} className='close'> 45 |
46 | {canLeave && 47 |
48 |
49 | 52 | 55 |
56 |
} 57 | {hasMembers && 58 | <> 59 |
60 | Channel Members 61 |
62 |
63 | 64 |
65 | } 66 |
67 | ) 68 | } 69 | 70 | export default connect(mapStateToProps, mapDispatchToProps)(ChannelPanel) 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cabal Desktop 2 | 3 | > Desktop client for cabal, the p2p/decentralized/offline-first chat platform. 4 | 5 |
Screen Shot 2020-06-05 at 10 29 00 AM
6 | 7 | ## Install 8 | 9 | ### Download the latest release 10 | 11 | https://github.com/cabal-club/cabal-desktop/releases/ 12 | 13 | ### Build from source 14 | 15 | ``` 16 | $ git clone https://github.com/cabal-club/cabal-desktop 17 | $ cd cabal-desktop 18 | 19 | $ yarn install # install dependencies 20 | $ yarn start # start the application 21 | ``` 22 | 23 | ### Build under NixOS 24 | [This gist](https://gist.github.com/cryptix/9dc8806fe44f266d47f550b23b703ff8) contains a `nix-shell` file for development purposes. It sidesteps the issue of packaging the full package tree as a release into nixpkgs. 25 | 26 | ### Download from AUR 27 | https://aur.archlinux.org/packages/cabal-desktop-git/ 28 | 29 | ### Updating MacOS DMG background image 30 | ``` 31 | tiffutil -cathidpicheck cabal-desktop-dmg-background.jpg cabal-desktop-dmg-background@2x.jpg -out dmg-background.tiff 32 | ``` 33 | 34 | ## Distribute 35 | 36 | TravisCI will automatically create and upload the appropriate release packages 37 | for you when you're ready to release. Here's the process for distributing 38 | production builds. 39 | 40 | 1. Draft a new release. Set the “Tag version” to the value of version in your 41 | application package.json, and prefix it with v. “Release title” can be anything 42 | you want. For example, if your application package.json version is 1.0, your draft’s 43 | “Tag version” would be v1.0. 44 | 45 | 2. Push some commits. Every CI build will update the artifacts attached to this 46 | draft. 47 | 48 | 3. Once you are done, create the tag (e.g., `git tag v6.0.0`) and publish the release (`git push --tags && npm publish`). GitHub will tag 49 | the latest commit for you. 50 | 51 | The benefit of this workflow is that it allows you to always have the latest 52 | artifacts, and the release can be published once it is ready. 53 | 54 | 55 | Build for current platform: 56 | 57 | ``` 58 | $ yarn run dist 59 | ``` 60 | 61 | build for [multiple platforms](https://www.electron.build/multi-platform-build#docker): 62 | 63 | ``` 64 | $ ./bin/build-multi 65 | ``` 66 | 67 | ## How to Contribute 68 | 69 | ### Formatting Rules 70 | 71 | This repository is formatted with [StandardJS](https://standardjs.com/) (there is a [vscode](https://marketplace.visualstudio.com/items?itemName=chenxsan.vscode-standardjs) plugin). 72 | -------------------------------------------------------------------------------- /app/containers/cabalsList.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { connect } from 'react-redux' 3 | import platform from '../platform' 4 | 5 | import { viewCabal, changeScreen } from '../actions' 6 | 7 | const mapStateToProps = state => { 8 | const cabal = state.cabals[state.currentCabal] 9 | const addr = cabal.addr 10 | return { 11 | addr, 12 | cabals: state.cabals, 13 | cabal, 14 | settings: state.cabalSettings || {}, 15 | username: cabal.username 16 | } 17 | } 18 | 19 | const mapDispatchToProps = dispatch => ({ 20 | changeScreen: ({ screen }) => dispatch(changeScreen({ screen })), 21 | viewCabal: ({ addr }) => dispatch(viewCabal({ addr })) 22 | }) 23 | 24 | class CabalsListScreen extends React.Component { 25 | joinCabal () { 26 | this.props.changeScreen({ screen: 'addCabal' }) 27 | } 28 | 29 | openSettings () { 30 | this.props.changeScreen({ screen: 'appSettings' }) 31 | } 32 | 33 | selectCabal (addr) { 34 | this.props.viewCabal({ addr }) 35 | } 36 | 37 | render () { 38 | var self = this 39 | var { addr, cabals, settings } = this.props 40 | cabals = cabals || {} 41 | var cabalKeys = (Object.keys(cabals) || []).sort() 42 | var className = [ 43 | 'client__cabals', 44 | ...(platform.mac? ['client__cabals__mac'] : []) 45 | ].join(' ') 46 | return ( 47 |
48 |
49 | {cabalKeys.map(function (key) { 50 | var cabal = cabals[key] 51 | if (cabal) { 52 | return ( 53 |
54 | 55 | {(settings[key]?.alias || key).slice(0, 2)} 56 | 57 | {cabal.allChannelsUnreadCount > 0 &&
} 58 |
59 | ) 60 | } 61 | })} 62 |
63 | 64 |
65 |
66 | {/*
67 |
68 | 69 |
70 |
*/} 71 |
72 | ) 73 | } 74 | } 75 | 76 | const CabalsList = connect(mapStateToProps, mapDispatchToProps)(CabalsListScreen) 77 | 78 | export default CabalsList 79 | -------------------------------------------------------------------------------- /app/containers/addCabal.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { connect } from 'react-redux' 3 | import { addCabal } from '../actions' 4 | 5 | const mapStateToProps = state => state 6 | 7 | const mapDispatchToProps = dispatch => ({ 8 | addCabal: ({ addr, isNewlyAdded, username }) => dispatch(addCabal({ addr, isNewlyAdded, username })), 9 | newCabal: (username) => dispatch(addCabal({ username })), 10 | hide: () => dispatch({ type: 'CHANGE_SCREEN', screen: 'main' }) 11 | }) 12 | 13 | class addCabalScreen extends Component { 14 | onClickClose () { 15 | this.props.hide() 16 | } 17 | 18 | newCabalPress (ev) { 19 | this.props.newCabal() 20 | this.props.hide() 21 | } 22 | 23 | onClickJoin () { 24 | var cabal = document.getElementById('add-cabal') 25 | var nickname = document.getElementById('nickname') 26 | if (cabal.value) { 27 | this.props.addCabal({ 28 | addr: cabal.value, 29 | isNewlyAdded: true, 30 | username: nickname.value 31 | }) 32 | this.props.hide() 33 | } 34 | } 35 | 36 | onPressEnter (event) { 37 | if (event.key !== 'Enter') return 38 | event.preventDefault() 39 | event.stopPropagation() 40 | this.onClickJoin() 41 | } 42 | 43 | render () { 44 | return ( 45 |
46 |
47 | {this.props.cabals && !!Object.keys(this.props.cabals).length && 48 | } 49 |
50 |

Cabal

51 |

52 | open-source decentralized private chat 53 |

54 |
55 | 63 | 71 | 72 | Join 73 | 74 |
75 |

76 | Don't have a swarm to join yet?

77 | 78 | Create a Cabal 79 | 80 |

81 |
82 | ) 83 | } 84 | } 85 | 86 | const AddCabalContainer = connect(mapStateToProps, mapDispatchToProps)(addCabalScreen) 87 | 88 | export default AddCabalContainer 89 | -------------------------------------------------------------------------------- /app/styles/react-contexify.css: -------------------------------------------------------------------------------- 1 | /* https://github.com/fkhadra/react-contexify/blob/master/dist/ReactContexify.min.css */ 2 | .react-contexify{position:fixed;opacity:0;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;background-color:#fff;box-sizing:border-box;box-shadow:0 10px 20px rgba(0,0,0,.3),0 0 0 1px #eee;padding:5px 0;min-width:180px}.react-contexify .react-contexify__submenu{position:absolute;top:0;pointer-events:none;transition:opacity .275s}.react-contexify__submenu-arrow{font-size:12px;position:absolute;right:10px;line-height:22px}.react-contexify__separator{float:left;width:100%;height:1px;cursor:default;margin:4px 0;background-color:rgba(0,0,0,.2)}.react-contexify__item{cursor:pointer;position:relative}.react-contexify__item:not(.react-contexify__item--disabled):hover>.react-contexify__item__content{color:#fff;background-color:#4393e6}.react-contexify__item:not(.react-contexify__item--disabled):hover>.react-contexify__submenu{pointer-events:auto;opacity:1}.react-contexify__item--disabled{cursor:default;opacity:.5}.react-contexify__item__content{padding:6px 12px;display:-ms-flexbox;display:flex;text-align:left;white-space:nowrap;color:#333;position:relative}.react-contexify__item__icon{font-size:20px;margin-right:5px;font-style:normal}.react-contexify__theme--dark{padding:6px 0;box-shadow:0 2px 15px rgba(0,0,0,.4),0 0 0 1px #222}.react-contexify__theme--dark,.react-contexify__theme--dark .react-contexify__submenu{background-color:rgba(40,40,40,.98)}.react-contexify__theme--dark .react-contexify__separator{background-color:#eee}.react-contexify__theme--dark .react-contexify__item__content{color:#fff}.react-contexify__theme--dark .react-contexify__item__icon{margin-right:8px;width:12px;text-align:center}.react-contexify__theme--light{padding:6px 0;box-shadow:0 2px 15px rgba(0,0,0,.2),0 0 0 1px #eee}.react-contexify__theme--light .react-contexify__separator{background-color:#eee}.react-contexify__theme--light .react-contexify__item:not(.react-contexify__item--disabled):hover>.react-contexify__item__content{color:#4393e6;background-color:#e0eefd}.react-contexify__theme--light .react-contexify__item__content{color:#666}.react-contexify__theme--light .react-contexify__item__icon{margin-right:8px;width:12px;text-align:center}@keyframes react-contexify__popIn{0%{transform:scale(0)}to{transform:scale(1)}}@keyframes react-contexify__popOut{0%{transform:scale(1)}to{transform:scale(0)}}.react-contexify__will-enter--pop{animation:react-contexify__popIn .3s cubic-bezier(.51,.92,.24,1.2)}.react-contexify__will-leave--pop{animation:react-contexify__popOut .3s cubic-bezier(.51,.92,.24,1.2)}@keyframes react-contexify__zoomIn{0%{opacity:0;transform:scale3d(.3,.3,.3)}50%{opacity:1}}@keyframes react-contexify__zoomOut{0%{opacity:1}50%{opacity:0;transform:scale3d(.3,.3,.3)}to{opacity:0}}.react-contexify__will-enter--zoom{transform-origin:top left;animation:react-contexify__zoomIn .4s}.react-contexify__will-leave--zoom{animation:react-contexify__zoomOut .4s}@keyframes react-contexify__fadeIn{0%{opacity:0}to{opacity:1}}@keyframes react-contexify__fadeOut{0%{opacity:1}to{opacity:0}}.react-contexify__will-enter--fade{animation:react-contexify__fadeIn .3s ease}.react-contexify__will-leave--fade{animation:react-contexify__fadeOut .3s ease}@keyframes react-contexify__flipInX{0%{transform:perspective(400px) rotateX(90deg);animation-timing-function:ease-in}40%{transform:perspective(400px) rotateX(-20deg);animation-timing-function:ease-in}60%{transform:perspective(400px) rotateX(10deg)}80%{transform:perspective(400px) rotateX(-5deg)}to{transform:perspective(400px)}}@keyframes react-contexify__flipOutX{0%{transform:perspective(400px)}30%{transform:perspective(400px) rotateX(-20deg);opacity:1}to{transform:perspective(400px) rotateX(90deg);opacity:0}}.react-contexify__will-enter--flip{animation:react-contexify__flipInX .65s}.react-contexify__will-enter--flip,.react-contexify__will-leave--flip{-webkit-backface-visibility:visible!important;backface-visibility:visible!important}.react-contexify__will-leave--flip{animation:react-contexify__flipOutX .65s} 3 | -------------------------------------------------------------------------------- /app/reducer.js: -------------------------------------------------------------------------------- 1 | import { createReducer } from '@reduxjs/toolkit' 2 | import { setAutoFreeze } from 'immer' 3 | import settings from './settings' 4 | 5 | // set auto freeze to false to prevent freezing the object. 6 | // currently data is shared between cabal-client lib and redux. 7 | // so if frozen, it will cause issues since lib is mutating some code.- 8 | setAutoFreeze(false) 9 | 10 | let darkMode = settings.get('darkMode') 11 | // if its not explicitly set, set it in darkMode 12 | if (typeof darkMode === 'undefined') darkMode = true 13 | 14 | const defaultState = { 15 | cabals: {}, 16 | cabalSettings: {}, 17 | cabalSettingsVisible: false, 18 | channelMembers: [], 19 | channelPanelVisible: {}, 20 | currentCabal: null, 21 | currentChannel: 'default', 22 | emojiPickerVisible: false, 23 | profilePanelUser: {}, 24 | profilePanelVisible: {}, 25 | screen: 'main', 26 | screenViewHistory: [], 27 | screenViewHistoryPosition: 0, 28 | globalSettings: { 29 | darkMode 30 | } 31 | } 32 | 33 | const reducer = createReducer(defaultState, { 34 | CHANGE_DARK_MODE: (state, { darkMode }) => { 35 | state.globalSettings.darkMode = darkMode 36 | }, 37 | CHANGE_SCREEN: (state, { screen, addr }) => { 38 | state.screen = screen 39 | state.addr = addr 40 | }, 41 | VIEW_CABAL: (state, { channel, addr }) => { 42 | state.currentCabal = addr 43 | state.currentChannel = channel || state.currentChannel 44 | }, 45 | ADD_CABAL: (state, action) => { 46 | state.cabals[action.addr] = { 47 | ...state.cabals[action.addr], 48 | messages: [], 49 | ...action 50 | } 51 | }, 52 | UPDATE_CABAL: (state, action = {}) => { 53 | state.cabals[action.addr] = { 54 | ...state.cabals[action.addr], 55 | ...action 56 | } 57 | }, 58 | UPDATE_CABAL_SETTINGS: (state, { addr, settings }) => { 59 | state.cabalSettings[addr] = { 60 | ...state.cabalSettings[addr], 61 | ...settings 62 | } 63 | }, 64 | UPDATE_TOPIC: (state, { addr, topic }) => { 65 | state.cabals[addr].topic = topic 66 | }, 67 | DELETE_CABAL: (state, { addr }) => { 68 | delete state.cabals[addr] 69 | }, 70 | SHOW_CHANNEL_BROWSER: (state) => { 71 | state.channelBrowserVisible = true 72 | // state.profilePanelVisible[addr] = false 73 | }, 74 | UPDATE_CHANNEL_BROWSER: (state, { channelsData }) => { 75 | state.channelBrowserChannelsData = channelsData 76 | }, 77 | SHOW_CABAL_SETTINGS: (state) => { 78 | state.cabalSettingsVisible = true 79 | state.emojiPickerVisible = false 80 | // state.profilePanelVisible[addr] = false 81 | }, 82 | HIDE_CABAL_SETTINGS: state => { state.cabalSettingsVisible = false }, 83 | HIDE_ALL_MODALS: state => { 84 | state.cabalSettingsVisible = false 85 | state.emojiPickerVisible = false 86 | state.channelBrowserVisible = false 87 | }, 88 | UPDATE_WINDOW_BADGE: (state, { badgeCount }) => { state.badgeCount = badgeCount }, 89 | SHOW_EMOJI_PICKER: (state) => { state.emojiPickerVisible = true }, 90 | HIDE_EMOJI_PICKER: state => { state.emojiPickerVisible = false }, 91 | SHOW_PROFILE_PANEL: (state, { addr, userKey }) => { 92 | state.profilePanelVisible[addr] = true 93 | state.profilePanelUser[addr] = userKey 94 | }, 95 | HIDE_PROFILE_PANEL: (state, { addr }) => { 96 | state.profilePanelVisible[addr] = false 97 | }, 98 | SHOW_CHANNEL_PANEL: (state, { addr }) => { 99 | state.channelPanelVisible[addr] = true 100 | }, 101 | HIDE_CHANNEL_PANEL: (state, { addr }) => { 102 | state.channelPanelVisible[addr] = false 103 | }, 104 | UPDATE_SCREEN_VIEW_HISTORY: (state, { addr, channel }) => { 105 | state.screenViewHistory.push({ addr, channel }) 106 | state.screenViewHistoryPosition = state.screenViewHistory.length - 1 107 | }, 108 | SET_SCREEN_VIEW_HISTORY_POSITION: (state, { index }) => { 109 | state.screenViewHistoryPosition = index 110 | } 111 | }) 112 | 113 | export default reducer 114 | -------------------------------------------------------------------------------- /static/fonts/Noto_Sans_JP/OFL.txt: -------------------------------------------------------------------------------- 1 | This Font Software is licensed under the SIL Open Font License, Version 1.1. 2 | This license is copied below, and is also available with a FAQ at: 3 | http://scripts.sil.org/OFL 4 | 5 | 6 | ----------------------------------------------------------- 7 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 8 | ----------------------------------------------------------- 9 | 10 | PREAMBLE 11 | The goals of the Open Font License (OFL) are to stimulate worldwide 12 | development of collaborative font projects, to support the font creation 13 | efforts of academic and linguistic communities, and to provide a free and 14 | open framework in which fonts may be shared and improved in partnership 15 | with others. 16 | 17 | The OFL allows the licensed fonts to be used, studied, modified and 18 | redistributed freely as long as they are not sold by themselves. The 19 | fonts, including any derivative works, can be bundled, embedded, 20 | redistributed and/or sold with any software provided that any reserved 21 | names are not used by derivative works. The fonts and derivatives, 22 | however, cannot be released under any other type of license. The 23 | requirement for fonts to remain under this license does not apply 24 | to any document created using the fonts or their derivatives. 25 | 26 | DEFINITIONS 27 | "Font Software" refers to the set of files released by the Copyright 28 | Holder(s) under this license and clearly marked as such. This may 29 | include source files, build scripts and documentation. 30 | 31 | "Reserved Font Name" refers to any names specified as such after the 32 | copyright statement(s). 33 | 34 | "Original Version" refers to the collection of Font Software components as 35 | distributed by the Copyright Holder(s). 36 | 37 | "Modified Version" refers to any derivative made by adding to, deleting, 38 | or substituting -- in part or in whole -- any of the components of the 39 | Original Version, by changing formats or by porting the Font Software to a 40 | new environment. 41 | 42 | "Author" refers to any designer, engineer, programmer, technical 43 | writer or other person who contributed to the Font Software. 44 | 45 | PERMISSION & CONDITIONS 46 | Permission is hereby granted, free of charge, to any person obtaining 47 | a copy of the Font Software, to use, study, copy, merge, embed, modify, 48 | redistribute, and sell modified and unmodified copies of the Font 49 | Software, subject to the following conditions: 50 | 51 | 1) Neither the Font Software nor any of its individual components, 52 | in Original or Modified Versions, may be sold by itself. 53 | 54 | 2) Original or Modified Versions of the Font Software may be bundled, 55 | redistributed and/or sold with any software, provided that each copy 56 | contains the above copyright notice and this license. These can be 57 | included either as stand-alone text files, human-readable headers or 58 | in the appropriate machine-readable metadata fields within text or 59 | binary files as long as those fields can be easily viewed by the user. 60 | 61 | 3) No Modified Version of the Font Software may use the Reserved Font 62 | Name(s) unless explicit written permission is granted by the corresponding 63 | Copyright Holder. This restriction only applies to the primary font name as 64 | presented to the users. 65 | 66 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 67 | Software shall not be used to promote, endorse or advertise any 68 | Modified Version, except to acknowledge the contribution(s) of the 69 | Copyright Holder(s) and the Author(s) or with their explicit written 70 | permission. 71 | 72 | 5) The Font Software, modified or unmodified, in part or in whole, 73 | must be distributed entirely under this license, and must not be 74 | distributed under any other license. The requirement for fonts to 75 | remain under this license does not apply to any document created 76 | using the Font Software. 77 | 78 | TERMINATION 79 | This license becomes null and void if any of the above conditions are 80 | not met. 81 | 82 | DISCLAIMER 83 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 84 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 85 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 86 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 87 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 88 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 89 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 90 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 91 | OTHER DEALINGS IN THE FONT SOFTWARE. 92 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cabal-desktop", 3 | "version": "7.0.0", 4 | "description": "Cabal p2p offline-first desktop application", 5 | "scripts": { 6 | "build": "cross-env NODE_ENV=production webpack", 7 | "start:electron": "electron .", 8 | "start:webpack": "webpack", 9 | "watch": "webpack --watch", 10 | "start": "cross-env NODE_ENV=development npm-run-all --parallel start:*", 11 | "pack": "electron-builder --dir", 12 | "dist": "yarn run build && electron-builder --publish=onTagOrDraft", 13 | "dist:multi": "electron-builder -mlw", 14 | "postinstall": "electron-builder install-app-deps", 15 | "test": "cross-env NODE_ENV=test BABEL_DISABLE_CACHE=1 jest --passWithNoTests" 16 | }, 17 | "productName": "Cabal", 18 | "repository": "cabal-club/cabal-desktop", 19 | "author": { 20 | "name": "Cabal Club", 21 | "email": "github-noreply@cabal.club" 22 | }, 23 | "license": "GPL-3.0", 24 | "devDependencies": { 25 | "@babel/core": "^7.9.0", 26 | "@babel/plugin-proposal-object-rest-spread": "^7.9.5", 27 | "@babel/plugin-proposal-optional-chaining": "^7.9.0", 28 | "@babel/plugin-transform-runtime": "^7.9.0", 29 | "@babel/preset-env": "^7.9.5", 30 | "@babel/preset-react": "^7.9.4", 31 | "babel-loader": "^8.1.0", 32 | "cross-env": "^7.0.2", 33 | "css-loader": "^3.5.3", 34 | "dotenv-webpack": "^6.0.0", 35 | "electron": "7.1.13", 36 | "electron-builder": "^22.9.1", 37 | "electron-notarize": "^1.0.0", 38 | "jest": "^25.4.0", 39 | "node-sass": "^4.14.0", 40 | "npm-run-all": "^4.1.5", 41 | "prettier-standard": "^16.3.0", 42 | "sass-loader": "^8.0.2", 43 | "standard": "^14.3.3", 44 | "style-loader": "^1.2.0", 45 | "webpack": "^4.43.0", 46 | "webpack-cli": "^3.3.11", 47 | "webpack-node-externals": "^1.7.2" 48 | }, 49 | "dependencies": { 50 | "@babel/runtime": "^7.7.7", 51 | "@reduxjs/toolkit": "^1.3.5", 52 | "babel-runtime": "^6.26.0", 53 | "cabal-client": "^7.2.0", 54 | "collect-stream": "^1.2.1", 55 | "dat-encoding": "^5.0.1", 56 | "debug": "^4.1.1", 57 | "deepmerge": "^4.2.2", 58 | "del": "^5.1.0", 59 | "electron-default-menu": "^1.0.1", 60 | "electron-is-dev": "^1.2.0", 61 | "electron-prompt": "^1.5.1", 62 | "electron-reload": "^1.5.0", 63 | "electron-store": "^5.2.0", 64 | "electron-updater": "^4.3.1", 65 | "electron-window-state": "^5.0.3", 66 | "emoji-mart": "^3.0.0", 67 | "get-form-data": "^3.0.0", 68 | "lodash": "^4.17.20", 69 | "mkdirp": "^1.0.4", 70 | "moment": "^2.24.0", 71 | "mousetrap": "^1.6.5", 72 | "ms": "^2.1.2", 73 | "react": "^16.13.1", 74 | "react-blockies": "^1.4.1", 75 | "react-contexify": "^4.1.1", 76 | "react-dom": "^16.13.1", 77 | "react-redux": "^7.2.0", 78 | "redux": "^4.0.5", 79 | "redux-logger": "^3.0.6", 80 | "redux-thunk": "^2.3.0", 81 | "remark": "^12.0.0", 82 | "remark-altprot": "^1.1.0", 83 | "remark-emoji": "^2.1.0", 84 | "remark-react": "^7.0.1", 85 | "sodium-native": "^3.2.0", 86 | "strftime": "^0.10.0", 87 | "to2": "^1.0.0" 88 | }, 89 | "build": { 90 | "appId": "club.cabal.desktop", 91 | "afterSign": "scripts/notarize.js", 92 | "productName": "Cabal", 93 | "publish": [ 94 | "github" 95 | ], 96 | "protocols": [ 97 | { 98 | "name": "cabal", 99 | "schemes": [ 100 | "cabal" 101 | ] 102 | } 103 | ], 104 | "mac": { 105 | "category": "public.app-category.utilities", 106 | "entitlements": "build/entitlements.mac.plist", 107 | "entitlementsInherit": "build/entitlements.mac.plist", 108 | "gatekeeperAssess": false, 109 | "hardenedRuntime": true 110 | }, 111 | "dmg": { 112 | "sign": false, 113 | "background": "static/images/dmg-background.tiff", 114 | "contents": [ 115 | { 116 | "x": 135, 117 | "y": 200 118 | }, 119 | { 120 | "x": 405, 121 | "y": 200, 122 | "type": "link", 123 | "path": "/Applications" 124 | } 125 | ], 126 | "artifactName": "cabal-desktop-${version}-mac.${ext}" 127 | }, 128 | "linux": { 129 | "target": [ 130 | "AppImage", 131 | "snap", 132 | "deb" 133 | ], 134 | "category": "Network" 135 | }, 136 | "appImage": { 137 | "artifactName": "cabal-desktop-${version}-linux-${arch}.${ext}" 138 | }, 139 | "win": { 140 | "publisherName": "cabal" 141 | }, 142 | "nsis": { 143 | "artifactName": "cabal-desktop-${version}-windows.${ext}" 144 | }, 145 | "files": [ 146 | "**/*", 147 | "!**/node_modules/pannellum/*.{jpg,png}", 148 | "!**/node_modules/sodium-native/prebuilds/linux-arm", 149 | "!**/node_modules/*/{CHANGELOG.md,README.md,README,readme.md,readme}", 150 | "!**/node_modules/**/{test,__tests__,tests,powered-test,example,examples}", 151 | "!**/node_modules/.bin", 152 | "!**/*.{iml,o,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,xproj}", 153 | "!.editorconfig", 154 | "!**/._*", 155 | "!**/{.DS_Store,.git,.hg,.svn,CVS,RCS,SCCS,.gitignore,.gitattributes}", 156 | "!**/{__pycache__,thumbs.db,.flowconfig,.idea,.vs,.nyc_output}", 157 | "!**/{appveyor.yml,.travis.yml,circle.yml}", 158 | "!**/{npm-debug.log,yarn.lock,.yarn-integrity,.yarn-metadata.json}", 159 | "!test${/*}" 160 | ] 161 | }, 162 | "np": { 163 | "yarn": false, 164 | "private": true 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /app/containers/cabalSettings.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { connect } from 'react-redux' 3 | import { clipboard } from 'electron' 4 | 5 | import { hideCabalSettings, removeCabal, saveCabalSettings } from '../actions' 6 | 7 | const mapStateToProps = state => { 8 | var cabal = state.cabals[state.currentCabal] 9 | return { 10 | cabal, 11 | settings: state.cabalSettings[cabal.addr] || {} 12 | } 13 | } 14 | 15 | const mapDispatchToProps = dispatch => ({ 16 | hideCabalSettings: () => dispatch(hideCabalSettings()), 17 | removeCabal: ({ addr }) => dispatch(removeCabal({ addr })), 18 | saveCabalSettings: ({ addr, settings }) => dispatch(saveCabalSettings({ addr, settings })) 19 | }) 20 | 21 | class CabalSettingsContainer extends React.Component { 22 | onClickCloseSettings () { 23 | this.props.hideCabalSettings() 24 | } 25 | 26 | onToggleOption (option) { 27 | const settings = this.props.settings 28 | settings[option] = !this.props.settings[option] 29 | this.props.saveCabalSettings({ addr: this.props.cabal.addr, settings }) 30 | } 31 | 32 | onClickCopyCode () { 33 | clipboard.writeText('cabal://' + this.props.cabal.addr) 34 | window.alert( 35 | 'Copied code to clipboard! Now give it to people you want to join your Cabal. Only people with the link can join.' 36 | ) 37 | } 38 | 39 | removeCabal (addr) { 40 | this.props.removeCabal({ addr }) 41 | } 42 | 43 | render () { 44 | const { enableNotifications, alias } = this.props.settings || {} 45 | 46 | return ( 47 |
48 |
49 |
50 |
51 |
52 |
53 |

54 | Settings 55 | 56 |

57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
Invite People
66 |
Share this key with others to let them join the cabal.
67 |
68 |
69 | 70 | 76 |
77 |
78 |
79 |
80 |
Cabal Name
81 |
Set a local name for this cabal. Only you can see this.
82 |
83 |
84 | this.props.saveCabalSettings({ addr: this.props.cabal.addr, settings: { ...this.props.settings, alias: e.target.value } })} /> 85 |
86 |
87 |
88 |
89 |
90 | { }} /> 91 |
92 |
93 |
Enable desktop notifications
94 |
Display a notification for new messages for this cabal when a channel is in the background.
95 |
96 |
97 |
98 |
99 |
100 |
Remove this cabal from this Cabal Desktop client
101 |
The local cabal database will remain and may also exist on peer clients.
102 |
103 |
104 | 105 |
106 |
107 |
108 |
109 |
110 |
111 | ) 112 | } 113 | } 114 | 115 | const CabalSettings = connect(mapStateToProps, mapDispatchToProps)(CabalSettingsContainer) 116 | 117 | export default CabalSettings 118 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { app, BrowserWindow, shell, Menu, ipcMain } = require('electron') 4 | const isDev = require('electron-is-dev') 5 | const windowStateKeeper = require('electron-window-state') 6 | const os = require('os') 7 | const path = require('path') 8 | const settings = require('./app/settings') 9 | const AutoUpdater = require('./app/updater') 10 | const platform = require('./app/platform') 11 | 12 | const updater = new AutoUpdater() 13 | 14 | // the window object 15 | let win 16 | 17 | if (isDev) { 18 | require('electron-reload')(__dirname, { 19 | electron: path.join(__dirname, 'node_modules', '.bin', 'electron'), 20 | hardResetMethod: 'exit' 21 | }) 22 | } 23 | 24 | const template = [ 25 | { 26 | label: 'Edit', 27 | submenu: [ 28 | { role: 'undo' }, 29 | { role: 'redo' }, 30 | { type: 'separator' }, 31 | { role: 'cut' }, 32 | { role: 'copy' }, 33 | { role: 'paste' }, 34 | { role: 'pasteandmatchstyle' }, 35 | { role: 'delete' }, 36 | { role: 'selectall' } 37 | ] 38 | }, 39 | { 40 | label: 'View', 41 | submenu: [ 42 | { role: 'reload' }, 43 | { role: 'forcereload' }, 44 | { role: 'toggledevtools' }, 45 | { type: 'separator' }, 46 | { role: 'resetzoom' }, 47 | { role: 'zoomin' }, 48 | { role: 'zoomout' }, 49 | { type: 'separator' }, 50 | { role: 'togglefullscreen' }, 51 | { 52 | label: 'Night Mode', 53 | type: 'checkbox', 54 | checked: settings.get('darkMode'), 55 | click (menuItem) { settings.set('darkMode', menuItem.checked); win.webContents.send('darkMode', menuItem.checked) } 56 | } 57 | ] 58 | }, 59 | { 60 | role: 'window', 61 | submenu: [ 62 | { role: 'minimize' }, 63 | { role: 'close' } 64 | ] 65 | }, 66 | { 67 | role: 'help', 68 | submenu: [ 69 | { 70 | label: 'Learn More', 71 | click () { require('electron').shell.openExternal('https://cabal.chat/') } 72 | }, 73 | { 74 | label: 'Report Issue', 75 | click () { require('electron').shell.openExternal('https://github.com/cabal-club/cabal-desktop/issues/new') } 76 | }, 77 | { 78 | label: 'Automatically Check for Updates', 79 | type: 'checkbox', 80 | checked: settings.get('auto-update'), 81 | click (menuItem) { 82 | settings.set('auto-update', menuItem.checked) 83 | menuItem.checked ? updater.start() : updater.stop() 84 | } 85 | } 86 | ] 87 | } 88 | ] 89 | 90 | if (platform.mac) { 91 | template.unshift({ 92 | label: 'Cabal', 93 | submenu: [ 94 | { role: 'about' }, 95 | { type: 'separator' }, 96 | { role: 'services', submenu: [] }, 97 | { type: 'separator' }, 98 | { role: 'hide' }, 99 | { role: 'hideothers' }, 100 | { role: 'unhide' }, 101 | { type: 'separator' }, 102 | { role: 'quit' } 103 | ] 104 | }) 105 | 106 | // Edit menu 107 | template[1].submenu.push( 108 | { type: 'separator' }, 109 | { 110 | label: 'Speech', 111 | submenu: [ 112 | { role: 'startspeaking' }, 113 | { role: 'stopspeaking' } 114 | ] 115 | } 116 | ) 117 | 118 | // Window menu 119 | template[3].submenu = [ 120 | { role: 'close' }, 121 | { role: 'minimize' }, 122 | { role: 'zoom' }, 123 | { type: 'separator' }, 124 | { role: 'front' } 125 | ] 126 | } 127 | 128 | const menu = Menu.buildFromTemplate(template) 129 | Menu.setApplicationMenu(menu) 130 | 131 | app.requestSingleInstanceLock() 132 | app.on('second-instance', (event, argv, cwd) => { 133 | app.quit() 134 | }) 135 | 136 | app.setAsDefaultProtocolClient('cabal') 137 | 138 | app.on('ready', () => { 139 | updater.start() 140 | const mainWindowState = windowStateKeeper({ 141 | defaultWidth: 800, 142 | defaultHeight: 600 143 | }) 144 | 145 | let windowOptions = { 146 | backgroundColor: '#1e1e1e', 147 | x: mainWindowState.x, 148 | y: mainWindowState.y, 149 | width: mainWindowState.width, 150 | height: mainWindowState.height, 151 | titleBarStyle: 'default', 152 | title: 'Cabal Desktop ' + app.getVersion(), 153 | webPreferences: { 154 | nodeIntegration: true 155 | } 156 | } 157 | 158 | if(platform.mac){ 159 | windowOptions.titleBarStyle = 'hiddenInset'; 160 | } 161 | 162 | win = new BrowserWindow(windowOptions) 163 | mainWindowState.manage(win) 164 | 165 | win.loadURL('file://' + path.join(__dirname, 'index.html')) 166 | Menu.setApplicationMenu(Menu.buildFromTemplate(template)) 167 | 168 | win.webContents.on('will-navigate', (event, url) => { 169 | event.preventDefault() 170 | shell.openExternal(url) 171 | }) 172 | 173 | // Protocol handler for osx 174 | app.on('open-url', (event, url) => { 175 | event.preventDefault() 176 | win.webContents.send('open-cabal-url', { url }) 177 | }) 178 | 179 | ipcMain.on('update-badge', (event, { badgeCount, showCount }) => { 180 | if (platform.mac) { 181 | const badge = showCount ? badgeCount : '•' 182 | app.dock.setBadge(badgeCount > 0 ? ('' + badge) : '') 183 | } else { 184 | app.setBadgeCount(badgeCount) 185 | } 186 | }) 187 | 188 | win.on('close', event => { 189 | if (!app.quitting) { 190 | event.preventDefault() 191 | win.hide() 192 | } 193 | if (!platform.mac) { 194 | app.quit() 195 | } 196 | }) 197 | 198 | app.on('activate', () => { 199 | win.show() 200 | }) 201 | }) 202 | 203 | app.on('window-all-closed', () => { 204 | if (!platform.mac) { 205 | app.quit() 206 | } 207 | }) 208 | 209 | app.on('before-quit', () => { 210 | app.quitting = true 211 | }) 212 | -------------------------------------------------------------------------------- /app/containers/channelBrowser.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { connect } from 'react-redux' 3 | import prompt from 'electron-prompt' 4 | 5 | import { 6 | hideAllModals, 7 | joinChannel, 8 | showChannelBrowser, 9 | unarchiveChannel 10 | } from '../actions' 11 | 12 | const mapStateToProps = state => { 13 | var cabal = state.cabals[state.currentCabal] 14 | return { 15 | addr: state.currentCabal, 16 | cabal, 17 | channels: state.channelBrowserChannelsData 18 | } 19 | } 20 | 21 | const mapDispatchToProps = dispatch => ({ 22 | hideAllModals: () => dispatch(hideAllModals()), 23 | joinChannel: ({ addr, channel }) => dispatch(joinChannel({ addr, channel })), 24 | showChannelBrowser: ({ addr }) => dispatch(showChannelBrowser({ addr })), 25 | unarchiveChannel: ({ addr, channel }) => dispatch(unarchiveChannel({ addr, channel })) 26 | }) 27 | 28 | class ChannelBrowserContainer extends React.Component { 29 | onClickClose () { 30 | this.props.hideAllModals() 31 | } 32 | 33 | onClickJoinChannel (channel) { 34 | this.props.joinChannel({ addr: this.props.addr, channel }) 35 | } 36 | 37 | onClickUnarchiveChannel (channel) { 38 | this.props.unarchiveChannel({ addr: this.props.addr, channel }) 39 | this.props.showChannelBrowser({ addr: this.props.addr }) 40 | } 41 | 42 | onClickNewChannel () { 43 | prompt({ 44 | title: 'Create a channel', 45 | label: 'New channel name', 46 | value: undefined, 47 | type: 'input' 48 | }).then((newChannelName) => { 49 | if (newChannelName && newChannelName.trim().length > 0) { 50 | this.props.joinChannel({ addr: this.props.addr, channel: newChannelName }) 51 | } 52 | }).catch(() => { 53 | console.log('cancelled new channel') 54 | }) 55 | } 56 | 57 | sortChannelsByName (channels) { 58 | return channels.sort((a, b) => { 59 | if (a && !b) return -1 60 | if (b && !a) return 1 61 | if (a.name && !b.name) return -1 62 | if (b.name && !a.name) return 1 63 | return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1 64 | }) 65 | } 66 | 67 | render () { 68 | const { channels } = this.props 69 | const channelsJoined = this.sortChannelsByName(channels.filter(c => c.joined && !c.archived) || []) 70 | const channelsNotJoined = this.sortChannelsByName(channels.filter(c => !c.joined && !c.archived) || []) 71 | const channelsArchived = this.sortChannelsByName(channels.filter(c => !c.joined && c.archived) || []) 72 | return ( 73 |
74 |
75 |
76 |
77 |
78 |
79 |

80 | Browse Channels 81 |

82 |
83 |
84 |
85 |
Create A New Channel
86 |
87 |
88 |
89 |
90 |
91 |

Channels you can join

92 |
93 | {channelsNotJoined.map((channel) => { 94 | return ( 95 |
101 |
{channel.name}
102 |
{channel.topic}
103 |
{channel.memberCount} {channel.memberCount === 1 ? 'person' : 'people'}
104 |
105 | ) 106 | })} 107 |
108 |

Channels you belong to

109 |
110 | {channelsJoined.map((channel) => { 111 | return ( 112 |
118 |
{channel.name}
119 |
{channel.topic}
120 |
{channel.memberCount} {channel.memberCount === 1 ? 'person' : 'people'}
121 |
122 | ) 123 | })} 124 |
125 | {!!channelsArchived.length && ( 126 | <> 127 |

Archived Channels

128 |
129 | {channelsArchived.map((channel) => { 130 | return ( 131 |
136 |
137 |
{channel.name}
138 |
{channel.topic}
139 |
{channel.memberCount} {channel.memberCount === 1 ? 'person' : 'people'}
140 |
141 | 148 |
149 | ) 150 | })} 151 |
152 | 153 | )} 154 |
155 |
156 |
157 |
158 | ) 159 | } 160 | } 161 | 162 | const ChannelBrowser = connect(mapStateToProps, mapDispatchToProps)(ChannelBrowserContainer) 163 | 164 | export default ChannelBrowser 165 | -------------------------------------------------------------------------------- /app/containers/profilePanel.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { connect } from 'react-redux' 3 | 4 | import { 5 | getUser, 6 | hideProfilePanel, 7 | moderationAddAdmin, 8 | moderationAddMod, 9 | moderationBlock, 10 | moderationHide, 11 | moderationRemoveAdmin, 12 | moderationRemoveMod, 13 | moderationUnhide, 14 | joinChannel 15 | } from '../actions' 16 | import Avatar from './avatar' 17 | 18 | const mapStateToProps = state => ({ 19 | addr: state.currentCabal, 20 | cabal: state.cabals[state.currentCabal] 21 | }) 22 | 23 | const mapDispatchToProps = dispatch => ({ 24 | getUser: ({ key }) => dispatch(getUser({ key })), 25 | hideProfilePanel: ({ addr }) => dispatch(hideProfilePanel({ addr })), 26 | moderationAddAdmin: ({ addr, channel, reason, userKey }) => dispatch(moderationAddAdmin({ addr, channel, reason, userKey })), 27 | moderationAddMod: ({ addr, channel, reason, userKey }) => dispatch(moderationAddMod({ addr, channel, reason, userKey })), 28 | moderationBlock: ({ addr, channel, reason, userKey }) => dispatch(moderationBlock({ addr, channel, reason, userKey })), 29 | moderationHide: ({ addr, channel, reason, userKey }) => dispatch(moderationHide({ addr, channel, reason, userKey })), 30 | moderationRemoveAdmin: ({ addr, channel, reason, userKey }) => dispatch(moderationRemoveAdmin({ addr, channel, reason, userKey })), 31 | moderationRemoveMod: ({ addr, channel, reason, userKey }) => dispatch(moderationRemoveMod({ addr, channel, reason, userKey })), 32 | moderationUnhide: ({ addr, channel, reason, userKey }) => dispatch(moderationUnhide({ addr, channel, reason, userKey })), 33 | joinChannel: ({ addr, channel }) => dispatch(joinChannel({ addr, channel })) 34 | }) 35 | 36 | function ProfilePanel (props) { 37 | const user = props.getUser({ key: props.userKey }) 38 | 39 | function onClickStartPM (e) { 40 | props.joinChannel({ addr: props.addr, channel: props.userKey }) 41 | } 42 | 43 | function onClickHideUserAll () { 44 | props.moderationHide({ 45 | addr: props.addr, 46 | userKey: user.key 47 | }) 48 | } 49 | 50 | function onClickUnhideUserAll () { 51 | props.moderationUnhide({ 52 | addr: props.addr, 53 | userKey: user.key 54 | }) 55 | } 56 | 57 | function onClickAddModAll () { 58 | props.moderationAddMod({ 59 | addr: props.addr, 60 | userKey: user.key 61 | }) 62 | } 63 | 64 | function onClickRemoveModAll () { 65 | props.moderationRemoveMod({ 66 | addr: props.addr, 67 | userKey: user.key 68 | }) 69 | } 70 | 71 | function onClickAddAdminAll () { 72 | props.moderationAddAdmin({ 73 | addr: props.addr, 74 | userKey: user.key 75 | }) 76 | } 77 | 78 | function onClickRemoveAdminAll () { 79 | props.moderationRemoveAdmin({ 80 | addr: props.addr, 81 | userKey: user.key 82 | }) 83 | } 84 | 85 | const isSelf = user.key === props.cabal.userkey 86 | 87 | return ( 88 |
89 |
90 | Profile 91 | props.hideProfilePanel({ addr: props.addr })} className='close'> 92 |
93 |
94 |
95 | 96 |
97 | {!!user.online && 98 |
} 99 | {!user.online && 100 |
} 101 |
102 |

{user.name}

103 |

{user.key}

104 |
105 | {isSelf 106 | ?
You
107 | : user.isAdmin() &&
Admin
} 108 | {user.isModerator() &&
Moderator
} 109 | {user.isHidden() &&
Hidden
} 110 |
111 |
112 |
113 |
114 | Messages 115 |
116 |
117 |
118 | 119 |
Start an encrypted 1-on-1 chat that only you and this peer can read.
120 |
121 |
122 | {!isSelf && 123 | <> 124 |
125 | Moderation 126 |
127 |
128 |
129 | {!user.isHidden() && 130 | <> 131 | 132 |
Hiding a peer hides all of their past and future messages in all channels.
133 | } 134 | {user.isHidden() && 135 | <> 136 | 137 |
Hiding a peer hides all of their past and future messages in all channels.
138 | } 139 | {!user.isModerator() && 140 | <> 141 | 142 |
Adding another user as a moderator for you will apply their moderation settings to how you see this cabal.
143 | } 144 | {user.isModerator() && 145 | <> 146 | 147 |
Adding another user as a moderator for you will apply their moderation settings to how you see this cabal.
148 | } 149 | {!user.isAdmin() && 150 | <> 151 | 152 |
Adding another user as an admin for you will apply their moderation settings to how you see this cabal.
153 | } 154 | {user.isAdmin() && 155 | <> 156 | 157 |
Adding another user as an admin for you will apply their moderation settings to how you see this cabal.
158 | } 159 |
160 |
161 | } 162 |
163 | ) 164 | } 165 | 166 | export default connect(mapStateToProps, mapDispatchToProps)(ProfilePanel) 167 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## [7.0.0] - 2021-12-10 6 | ### Added 7 | - Private messaging! Thanks @cblgh and @nikolaiwarner! 8 | 9 | ## [6.0.8] - 2021-05-01 10 | ### Fixed 11 | - Fixed issues with duplicate messages! Thanks @cblgh and @nikolaiwarner! 12 | - Fixed a crash when using slash commands. Thanks @nikolaiwarner! 13 | 14 | ## [6.0.7] - 2021-03-30 15 | ### Added 16 | - Added channel archiving feature. Thanks @nikolaiwarner! 17 | - Added frameless window style to MacOS client. Thanks @gronis! 18 | ### Fixed 19 | - Fixed issues with message list scroll position! Thanks @gronis! 20 | - Fixed a crash when removing cabals. Thanks @nikolaiwarner! 21 | - Fixed close button visibility in dark mode. Thanks @gronis! 22 | 23 | ## [6.0.6] - 2021-01-15 24 | ### Added 25 | - Added dark mode as application menu setting. Thanks @khubo! 26 | - Added clickable links in messages for hyper/dat/cabal urls. Thanks @leeclarke! 27 | - Added a scrollbar to the channel/peer list on hover. Thanks @nikolaiwarner! 28 | - UI style improvements. Thanks @cblgh! 29 | - Rendering performance improvements. Thanks @khubo! 30 | - Added tooltips to links in messages to reveal the url. Thanks @sylvainDNS! 31 | ### Fixed 32 | - Fixed a crash on the channel/user detail pane when on status channel. Thanks @nikolaiwarner! 33 | - Fixed scrolling to bottom issues when switching channels. Thanks @khubo! 34 | 35 | ## [6.0.5] - 2020-08-27 36 | ### Added 37 | - Added support for whisperlinks (ephemeral user-defined aliases for cabal keys, see /whisper). Thanks @cblgh! 38 | - Pressing Shift+Enter inserts a newline after cursor position. Thanks @josephmturner! 39 | ### Fixed 40 | - Fixed some memory leaks. Still plenty more to find though! 41 | 42 | ## [6.0.4] - 2020-06-23 43 | ### Fixed 44 | - Fixed crashes related to new moderation features. 45 | 46 | ## [6.0.3] - 2020-06-12 47 | ### Added 48 | - Optimized newly adding cabal sync and reduced overall boot time. 49 | ### Fixed 50 | - Fixed crash related to moderation. 51 | - Fixed initialization crash. 52 | 53 | ## [6.0.2] - 2020-06-11 54 | ### Added 55 | - Added support for adding cabals using domain names. 56 | ### Fixed 57 | - Fixed bug related to adding new cabals. 58 | 59 | ## [6.0.1] - 2020-06-08 60 | ### Added 61 | - Added autoupdate feature to automatically install new releases. 62 | 63 | ## [6.0.0] - 2020-06-07 64 | ### Fixed 65 | - Update to latest `cabal-client` to fix connections to peers who require holepunching. 66 | 67 | ## [5.1.0] - 2020-06-06 68 | ### Added 69 | - Added basic moderation features for hiding users and setting moderators and admins. 70 | - Added indicator for unread messages in collapsed channels list. 71 | - Optimized incoming message handling. 72 | - Added a dark mode theme. 73 | ### Fixed 74 | - Fixed desktop notifications appearing from channels you have not joined. 75 | 76 | ## [5.0.5] - 2020-05-19 77 | ### Added 78 | - Added "starred/favorite" channels list. 79 | - Clicking the star next to a channels name in the header will add it to the starred list. 80 | - Added toggles to the channel and peers lists to hide or show them. 81 | - Clicking usernames will now show a profile panel with info about the user. 82 | - Added initial support for right-click context menus. 83 | - Added a custom font. 84 | - Added an indicator when there are newer messages in the message list. 85 | - MacOS builds are now signed and notarized reducing warnings during install. 86 | - MacOS DMG builds now have a custom background. 87 | - Added keyboard commands for cmd+arrow to navigate channels and cabals. 88 | - Avatars are now generated based on the user's unique key. 89 | ### Fixed 90 | - Fixed issue crash on username change on new cabals. 91 | - Fixed bug in removing cabals. 92 | - Fixed issue causing message list to incorrectly jump back in time. 93 | - Fixed issue preventing desktop notifications. 94 | - Fixed message parsing for urls and markdown. 95 | 96 | ## [5.0.4] - 2020-05-07 97 | ### Fixed 98 | - Fixed issue with missing icon. 99 | 100 | ## [5.0.3] - 2020-05-05 101 | ### Fixed 102 | - Fixed bug in removing cabals. 103 | ### Added 104 | - Added slash command handling from `cabal-client`. 105 | - Improved loading screen experience. 106 | - Duplicated nicks are now shown as one. 107 | 108 | ## [5.0.2] - 2020-04-21 109 | ### Fixed 110 | - Fixed additional performance issues in event handling. 111 | ### Added 112 | - Added a loading screen while cabals initialize to reduce UI flashing. 113 | - Updated to Electron 7. 114 | 115 | ## [5.0.1] - 2020-04-12 116 | ### Fixed 117 | - Fixed performance issues in event handling. 118 | 119 | ## [5.0.0] - 2020-04-10 120 | ### Added 121 | - Updated to latest `cabal-client` which now uses `hyperswarm` for connecting to peers. 122 | This is a breaking change and all clients will need to update to a client 123 | that supports hyperswarm to continuing peering. 124 | 125 | ## [4.1.0] - 2020-02-09 126 | ### Added 127 | - Implemented `cabal-client` into Cabal Desktop. 128 | - Added joining and leaving channels feature. 129 | - Added channel browser interface. 130 | - Improved unread message handling. 131 | - Added version number to UI. 132 | - Added a random nickname generator for the initiator a new cabal. 133 | - MacOS: Cabal Desktop will continue running when all windows have closed. 134 | ### Fixed 135 | - Desktop notifications are now throttled so not to flood you on startup. 136 | - Fixed message layout and style issues. 137 | 138 | ## [4.0.0] - 2019-11-30 139 | ### Added 140 | - Improved message rendering speed. 141 | - Added keyboard shortcuts to switch between cabals. 142 | - Added UI to indicate and divide date changes between messages. 143 | ### Fixed 144 | - Upgraded to cabal-core @ 9. 145 | 146 | ## [3.1.1] - 2019-08-09 147 | ### Fixed 148 | - Upgraded to latest cabal-core / multifeed 149 | 150 | ## [3.1.0] - 2019-07-26 151 | ### Added 152 | - Upgraded to Electron 5 153 | - Toggle previous and next channels with cmd/ctr-n and cmd/ctr-p key combo. 154 | - Added setting screen for each cabal. 155 | - Added toggle for enabling desktop notifications (they're off by default now). 156 | - Added join button to create cabal UI. 157 | - Window position and size are remembered between sessions. 158 | - Fix navigation to other cabal after deleting a cabal. 159 | - Added new message indicator to cabals list. 160 | - Added new message indicator badge to application icon (MacOS/Linux only). 161 | - Added feature to set an alias locally to give cabals friendly names in the UI. 162 | ### Fixed 163 | - Travis CI integration. it builds automatically now! 164 | - `/remove` command works again. 165 | - Navigate to cabal when adding a cabal address that already exists in the client. 166 | - Fixed jumpy message list scrolling when new messages arrive. 167 | - Fixed broken unread message indicators on channels. 168 | - Fixed large image embeds from taking up too much space. 169 | - Adjusted unordered lists margin to be more reasonable. 170 | - Emoji picker works again 171 | 172 | ## [3.0.0] - 2019-07-03 173 | ### Fixed 174 | - Tab completion of usernames and slash commands. 175 | - After switching cabals, posting a message will now send to the correct channel. 176 | 177 | ### Added 178 | - Upgraded to cabal-core@6 - this is a breaking change 179 | - Updated and added styling for Markdown rendering, including: `
` and headings. 180 | 181 | ## [2.0.3] - 2019-06-18 182 | ### Added 183 | - UI to show and set channel topics. 184 | - Display currently connected peer count. 185 | - Settings screen for future features and a button to remove the cabal from the client. 186 | - Slash commands for `/help`, `/join`/`/j`, `/motd` (message of the day), `/nick`/`/n`, `/topic`, and `/remove` (for removing a cabal from the client). -------------------------------------------------------------------------------- /app/styles/darkmode.scss: -------------------------------------------------------------------------------- 1 | /* 2 | ------------------------------------------------------ 3 | DARKMODE 4 | ------------------------------------------------------ 5 | 6 | This is a temporary hack so we can enjoy darkmode in the short term until we finish 7 | a better approach for customizing styles in `cabal-ui` :D 8 | */ 9 | 10 | $backgroundColor: #16161d; 11 | $backgroundColor2: #222; 12 | $backgroundColor3: #333; 13 | $borderColor: #444; 14 | $borderColor2: #888; 15 | $borderColor3: #ddd; 16 | $borderColor4: #fff; 17 | $buttonTextColor: #ccc; 18 | $buttonBackgroundColor: #1f0f50; 19 | $highlightColor: #9571D6; 20 | $linkColor: #4393e6; 21 | $textColor: #fff; 22 | $textColor2: #aaa; 23 | 24 | 25 | .client.darkmode { 26 | color: $textColor2; 27 | 28 | a { 29 | color: $linkColor; 30 | } 31 | 32 | .button { 33 | background-color: $buttonBackgroundColor; 34 | border-color: $highlightColor; 35 | color: $highlightColor; 36 | } 37 | .button:hover { 38 | border-color: $highlightColor; 39 | color: $buttonTextColor; 40 | } 41 | 42 | // .client__cabals { 43 | // background-color: $backgroundColor; 44 | // border-right: 1px solid $borderColor; 45 | // color: $textColor; 46 | // } 47 | 48 | // .client__cabals .switcher__item { 49 | // border: 2px solid $borderColor3; 50 | // color: $textColor; 51 | // } 52 | 53 | // .client__cabals .switcher__item:hover { 54 | // border: 2px solid rgba(255, 255, 255, 0.5); 55 | // color: $textColor2; 56 | // } 57 | 58 | // .client__cabals .switcher__item--active { 59 | // background-color: $highlightColor; 60 | // border: 2px solid $highlightColor; 61 | // color: $textColor !important; 62 | // } 63 | 64 | // .client__cabals .unreadIndicator { 65 | // background: $highlightColor; 66 | // } 67 | 68 | // .client__cabals .client__cabals__footer { 69 | // .settingsButton { 70 | // &:hover { 71 | // color: $textColor; 72 | // } 73 | // } 74 | // } 75 | 76 | .client__sidebar { 77 | background-color: $backgroundColor; 78 | color: $textColor; 79 | 80 | .session .session__meta h2 { 81 | color: $textColor2; 82 | } 83 | 84 | .session .session__configuration { 85 | // background-color: rgba(255, 255, 255, 0); 86 | } 87 | 88 | .collection { 89 | border-top-color: $borderColor; 90 | } 91 | 92 | .collection .collection__heading .collection__heading__title { 93 | color: $textColor2; 94 | } 95 | 96 | .collection .collection__item:hover { 97 | background-color: $backgroundColor2; 98 | } 99 | 100 | .collection .collection__item .collection__item__content { 101 | color: $textColor2; 102 | } 103 | 104 | .collection .collection__item .collection__item__messagesUnreadCount { 105 | color: $textColor; 106 | background-color: $highlightColor; 107 | } 108 | 109 | .collection .collection__item.active .collection__item__content, 110 | .collection .collection__item .collection__item__content.active { 111 | color: $textColor; 112 | } 113 | } 114 | 115 | .client__main { 116 | background-color: $backgroundColor; 117 | 118 | .window { 119 | border-left: 1px solid $borderColor; 120 | } 121 | 122 | .window__header { 123 | background-color: $backgroundColor; 124 | 125 | &.private { 126 | background-color: #693afa50; 127 | } 128 | } 129 | 130 | .channel-meta { 131 | border-bottom: 1px solid $borderColor; 132 | 133 | .channel-meta__data__details h1 { 134 | color: $textColor; 135 | } 136 | 137 | .channel-meta__data__details h2 { 138 | color: $textColor2; 139 | } 140 | 141 | .channel-meta__other .channel-meta__other__more { 142 | filter: invert(100%); 143 | } 144 | } 145 | .messages__item { 146 | &:hover { 147 | background-color: $backgroundColor2; 148 | } 149 | } 150 | 151 | .messages__item--system { 152 | background: $backgroundColor; 153 | } 154 | 155 | .messages__item__metadata { 156 | .messages__item__metadata__name { 157 | color: $textColor; 158 | } 159 | 160 | span { 161 | color: $textColor2; 162 | } 163 | 164 | div.text { 165 | pre { 166 | background-color: $backgroundColor3; 167 | } 168 | 169 | p code { 170 | background-color: $backgroundColor3; 171 | } 172 | 173 | blockquote { 174 | background: $backgroundColor3; 175 | } 176 | } 177 | 178 | a.link { 179 | color: $linkColor; 180 | 181 | &:hover { 182 | color: $linkColor; 183 | } 184 | } 185 | 186 | .cabal-settings__close { 187 | filter: invert(100%); 188 | } 189 | } 190 | 191 | .cabal-settings__item { 192 | border-bottom: 1px solid $borderColor; 193 | 194 | .cabal-settings__item-input { 195 | input { 196 | color: $textColor; 197 | background-color: $backgroundColor3; 198 | 199 | &.cabalKey { 200 | 201 | } 202 | } 203 | } 204 | } 205 | 206 | .cabal-settings__item-label-description { 207 | color: $textColor2; 208 | } 209 | 210 | .composer { 211 | background-color: $backgroundColor3; 212 | border: 2px solid $borderColor; 213 | 214 | .composer__input textarea { 215 | color: $textColor; 216 | background-color: $backgroundColor3; 217 | } 218 | 219 | &:hover, &:active { 220 | border-color: $borderColor2; 221 | } 222 | 223 | .composer__meta { 224 | border-right: 2px solid $borderColor; 225 | } 226 | 227 | .composer__other { 228 | filter: invert(100%) 229 | } 230 | } 231 | } 232 | 233 | .modalScreen { 234 | background: $backgroundColor; 235 | 236 | .modalScreen__header { 237 | .modalScreen__close { 238 | background-color: $backgroundColor; 239 | color: $textColor2; 240 | 241 | &:hover { 242 | color: $textColor; 243 | } 244 | } 245 | } 246 | } 247 | 248 | .modal-overlay { 249 | background-color: $backgroundColor; 250 | } 251 | 252 | .modal-overlay .modal { 253 | background-color: $backgroundColor; 254 | color: $textColor; 255 | } 256 | 257 | .modal-overlay .modal li a, 258 | .modal-overlay .modal li button { 259 | color: $textColor2; 260 | background: none; 261 | } 262 | 263 | .modal-overlay .modal li a:hover, 264 | .modal-overlay .modal li button:hover { 265 | background-color: $backgroundColor3; 266 | } 267 | 268 | ::-webkit-scrollbar-thumb { 269 | background-color: $backgroundColor3; 270 | } 271 | 272 | .sidebar ::-webkit-scrollbar-thumb { 273 | background-color: $backgroundColor3; 274 | } 275 | 276 | .messages__date__divider { 277 | h2 { 278 | color: $borderColor; 279 | } 280 | 281 | h2:before, 282 | h2:after { 283 | background-color: $borderColor; 284 | } 285 | } 286 | 287 | .channelBrowser__sectionTitle { 288 | border-bottom: 1px solid $borderColor; 289 | } 290 | 291 | .channelBrowser__row { 292 | border-bottom: 1px solid $borderColor; 293 | 294 | &:hover { 295 | background-color: $backgroundColor2; 296 | } 297 | 298 | .topic { 299 | color: $highlightColor; 300 | } 301 | 302 | .members { 303 | color: $textColor2; 304 | } 305 | } 306 | 307 | 308 | .panel { 309 | background-color: $backgroundColor; 310 | border-left-color: $borderColor; 311 | 312 | .panel__header { 313 | border-bottom-color: $borderColor; 314 | 315 | .close { 316 | filter: invert(100%); 317 | } 318 | } 319 | 320 | .panel__content { 321 | .collection__item { 322 | &:hover { 323 | background: $backgroundColor3; 324 | } 325 | } 326 | } 327 | 328 | .section__header { 329 | border-top-color: $borderColor; 330 | } 331 | } 332 | 333 | .profilePanel { 334 | .panel__header { 335 | background: $backgroundColor; 336 | } 337 | 338 | .avatar__online__indicator { 339 | border-color: $backgroundColor; 340 | } 341 | 342 | .help__text { 343 | color: $textColor2; 344 | } 345 | } 346 | } 347 | -------------------------------------------------------------------------------- /app/containers/messages.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { connect } from 'react-redux' 3 | import moment from 'moment' 4 | 5 | import { 6 | getUser, 7 | showProfilePanel 8 | } from '../actions' 9 | import Avatar from './avatar' 10 | import { currentChannelMessagesSelector, currentChannelSelector } from '../selectors' 11 | 12 | const mapStateToProps = state => ({ 13 | addr: state.currentCabal, 14 | messages: currentChannelMessagesSelector(state), 15 | channel: currentChannelSelector(state) 16 | }) 17 | 18 | const mapDispatchToProps = dispatch => ({ 19 | getUser: ({ key }) => dispatch(getUser({ key })), 20 | showProfilePanel: ({ addr, userKey }) => dispatch(showProfilePanel({ addr, userKey })) 21 | }) 22 | 23 | function MessagesContainer(props) { 24 | const onClickProfile = (user) => { 25 | props.showProfilePanel({ 26 | addr: props.addr, 27 | userKey: user.key 28 | }) 29 | } 30 | 31 | const renderDate = (time) => { 32 | return ( 33 | 34 | {time.short} 35 | {time.long} 36 | 37 | ) 38 | } 39 | 40 | const seen = {} 41 | const messages = (props.messages ?? []).filter((message) => { 42 | const messageId = message.key + message.message.seq 43 | if (typeof seen[messageId] === 'undefined') { 44 | seen[messageId] = true 45 | return true 46 | } 47 | return false 48 | }) 49 | 50 | let lastDividerDate = moment() // hold the time of the message for which divider was last added 51 | 52 | if (messages.length === 0 && props.channel !== '!status') { 53 | return ( 54 |
55 | This is a new channel. Send a message to start things off! 56 |
57 | ) 58 | } else { 59 | const defaultSystemName = 'Cabalbot' 60 | let prevMessage = {} 61 | return ( 62 | <> 63 |
64 | {messages.map((message) => { 65 | // Hide messages from hidden users 66 | const user = message.user 67 | if (user && user.isHidden()) return null 68 | 69 | const enriched = message.enriched 70 | // avoid comaprison with other types of message than chat/text 71 | 72 | const repeatedAuthor = message.key === prevMessage.key && prevMessage.type === 'chat/text' 73 | const printDate = moment(enriched.time) 74 | const formattedTime = { 75 | short: printDate.format('h:mm A'), 76 | long: printDate.format('LL') 77 | } 78 | // divider only needs to be added if its a normal message 79 | // and if day has changed since the last divider 80 | const showDivider = message.content && !lastDividerDate.isSame(printDate, 'day') 81 | if (showDivider) { 82 | lastDividerDate = printDate 83 | } 84 | let item = (
) 85 | prevMessage = message 86 | if (message.type === 'status') { 87 | item = ( 88 |
89 |
90 |
91 | 92 |
93 |
94 |
95 |
{message.name || defaultSystemName}{renderDate(formattedTime)}
96 |
{enriched.content}
97 |
98 |
99 | ) 100 | } 101 | if (message.type === 'chat/moderation') { 102 | const { role, type, issuerid, receiverid, reason } = message.message.value.content 103 | const issuer = props.getUser({ key: issuerid }) 104 | const receiver = props.getUser({ key: receiverid }) 105 | const issuerName = issuer && issuer.name ? issuer.name : issuerid.slice(0, 8) 106 | const receiverName = receiver && receiver.name ? receiver.name : receiverid.slice(0, 8) 107 | item = ( 108 |
109 |
110 |
111 | 112 |
113 |
114 |
115 |
{message.name || defaultSystemName}{renderDate(formattedTime)}
116 |
117 | {role === 'hide' && 118 |
119 | {issuerName} {(type === 'add' ? 'hid' : 'unhid')} {receiverName} 120 |
} 121 | {role !== 'hide' && 122 |
123 | {issuerName} {(type === 'add' ? 'added' : 'removed')} {receiverName} as {role} 124 |
} 125 | {!!reason && 126 |
({reason})
} 127 |
128 |
129 |
130 | ) 131 | } 132 | if (message.type === 'chat/text') { 133 | item = ( 134 |
135 |
136 | {repeatedAuthor ? null : } 137 |
138 |
139 | {!repeatedAuthor && 140 |
141 | {user.name} 142 | {user.isAdmin() && @} 143 | {!user.isAdmin() && user.isModerator() && %} 144 | {renderDate(formattedTime)} 145 |
} 146 |
147 | {enriched.content} 148 |
149 |
150 |
151 | ) 152 | } 153 | if (message.type === 'chat/emote') { 154 | item = ( 155 |
156 |
157 |
158 | {repeatedAuthor ? null : } 159 |
160 |
161 |
162 | {repeatedAuthor ? null :
{user.name}{renderDate(formattedTime)}
} 163 |
{enriched.content}
164 |
165 |
166 | ) 167 | } 168 | return ( 169 |
170 | {showDivider && ( 171 |
172 |

{formattedTime.long} ({printDate.fromNow()})

173 |
174 | )} 175 | {item} 176 |
177 | ) 178 | })} 179 |
180 | 181 | ) 182 | } 183 | } 184 | 185 | export default connect(mapStateToProps, mapDispatchToProps)(MessagesContainer) 186 | -------------------------------------------------------------------------------- /app/containers/mainPanel.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { ipcRenderer } from 'electron' 3 | import { connect } from 'react-redux' 4 | import prompt from 'electron-prompt' 5 | import { debounce } from 'lodash' 6 | import { 7 | changeScreen, 8 | hideEmojiPicker, 9 | leaveChannel, 10 | saveCabalSettings, 11 | setChannelTopic, 12 | showCabalSettings, 13 | showChannelPanel, 14 | viewCabal 15 | } from '../actions' 16 | import CabalSettings from './cabalSettings' 17 | import ChannelBrowser from './channelBrowser' 18 | import WriteContainer from './write' 19 | import MessagesContainer from './messages' 20 | import { currentChannelMemberCountSelector } from '../selectors' 21 | 22 | const mapStateToProps = state => { 23 | const cabal = state.cabals[state.currentCabal] 24 | const addr = cabal.addr 25 | return { 26 | addr, 27 | cabal, 28 | cabals: state.cabals, 29 | cabalSettingsVisible: state.cabalSettingsVisible, 30 | channelBrowserVisible: state.channelBrowserVisible, 31 | channelMemberCount: currentChannelMemberCountSelector(state), 32 | emojiPickerVisible: state.emojiPickerVisible, 33 | settings: state.cabalSettings[addr] || {} 34 | } 35 | } 36 | 37 | const mapDispatchToProps = dispatch => ({ 38 | changeScreen: ({ screen, addr }) => dispatch(changeScreen({ screen, addr })), 39 | hideEmojiPicker: () => dispatch(hideEmojiPicker()), 40 | leaveChannel: ({ addr, channel }) => dispatch(leaveChannel({ addr, channel })), 41 | saveCabalSettings: ({ addr, settings }) => dispatch(saveCabalSettings({ addr, settings })), 42 | setChannelTopic: ({ addr, channel, topic }) => 43 | dispatch(setChannelTopic({ addr, channel, topic })), 44 | showCabalSettings: ({ addr }) => dispatch(showCabalSettings({ addr })), 45 | showChannelPanel: ({ addr }) => dispatch(showChannelPanel({ addr })), 46 | viewCabal: ({ addr }) => dispatch(viewCabal({ addr })) 47 | }) 48 | 49 | class MainPanel extends Component { 50 | constructor (props) { 51 | super(props) 52 | this.state = { 53 | showScrollToBottom: false, 54 | shouldAutoScroll: true 55 | } 56 | this.refScrollContainer = null 57 | this.handleOpenCabalUrl = this.handleOpenCabalUrl.bind(this) 58 | this.setScrollToBottomButtonStatus = this.setScrollToBottomButtonStatus.bind(this) 59 | this.scrollToBottom = this.scrollToBottom.bind(this) 60 | this.onScrollMessagesUpdateBottomStatus = debounce(this.setScrollToBottomButtonStatus, 500, { 61 | leading: true, 62 | trailing: true 63 | }) 64 | } 65 | 66 | addEventListeners () { 67 | const self = this 68 | this.refScrollContainer?.addEventListener( 69 | 'scroll', 70 | self.onScrollMessages.bind(this) 71 | ) 72 | this.refScrollContainer?.addEventListener( 73 | 'scroll', 74 | self.onScrollMessagesUpdateBottomStatus.bind(this) 75 | ) 76 | } 77 | 78 | removeEventListeners () { 79 | const self = this 80 | this.refScrollContainer?.removeEventListener( 81 | 'scroll', 82 | self.onScrollMessages.bind(this) 83 | ) 84 | this.refScrollContainer?.removeEventListener( 85 | 'scroll', 86 | self.onScrollMessagesUpdateBottomStatus.bind(this) 87 | ) 88 | } 89 | 90 | componentDidMount () { 91 | this.addEventListeners() 92 | ipcRenderer.on('open-cabal-url', (event, arg) => { 93 | this.handleOpenCabalUrl(arg) 94 | }) 95 | } 96 | 97 | setScrollToBottomButtonStatus () { 98 | const totalHeight = this.refScrollContainer?.scrollHeight 99 | const scrolled = this.refScrollContainer?.scrollTop + 100 100 | const containerHeight = this.refScrollContainer?.offsetHeight 101 | if (scrolled < totalHeight - containerHeight) { 102 | this.setState({ 103 | showScrollToBottom: true 104 | }) 105 | } else if (scrolled >= totalHeight - containerHeight) { 106 | this.setState({ 107 | showScrollToBottom: false 108 | }) 109 | } 110 | 111 | this.scrollToBottom() 112 | } 113 | 114 | componentWillUnmount () { 115 | this.removeEventListeners() 116 | } 117 | 118 | componentDidUpdate (prevProps) { 119 | const changedScreen = ( 120 | (prevProps.channelBrowserVisible !== this.props.channelBrowserVisible) || 121 | (prevProps.cabalSettingsVisible !== this.props.cabalSettingsVisible) || 122 | (prevProps.settings?.currentChannel !== this.props.settings?.currentChannel) 123 | ) 124 | if (changedScreen) { 125 | this.removeEventListeners() 126 | this.addEventListeners() 127 | this.scrollToBottom(true) 128 | } 129 | if (prevProps.cabal !== this.props.cabal) { 130 | if (document.hasFocus()) { 131 | this.scrollToBottom() 132 | } else { 133 | this.setScrollToBottomButtonStatus() 134 | } 135 | } 136 | } 137 | 138 | onClickTopic () { 139 | prompt({ 140 | title: 'Set channel topic', 141 | label: 'New topic', 142 | value: this.props.cabal.topic, 143 | type: 'input' 144 | }) 145 | .then(topic => { 146 | if (topic && topic.trim().length > 0) { 147 | this.props.cabal.topic = topic 148 | this.props.setChannelTopic({ 149 | addr: this.props.cabal.addr, 150 | channel: this.props.cabal.channel, 151 | topic 152 | }) 153 | } 154 | }) 155 | .catch(() => { 156 | console.log('cancelled new topic') 157 | }) 158 | } 159 | 160 | onToggleFavoriteChannel (channelName) { 161 | const favorites = [...(this.props.settings['favorite-channels'] || [])] 162 | const index = favorites.indexOf(channelName) 163 | if (index > -1) { 164 | favorites.splice(index, 1) 165 | } else { 166 | favorites.push(channelName) 167 | } 168 | const settings = this.props.settings 169 | settings['favorite-channels'] = favorites 170 | this.props.saveCabalSettings({ addr: this.props.cabal.addr, settings }) 171 | } 172 | 173 | handleOpenCabalUrl ({ url = '' }) { 174 | const addr = url.replace('cabal://', '').trim() 175 | if (this.props.cabals[addr]) { 176 | this.props.viewCabal({ addr }) 177 | } else { 178 | this.props.changeScreen({ screen: 'addCabal', addr: url }) 179 | } 180 | } 181 | 182 | onScrollMessages (event) { 183 | var element = event.target 184 | var shouldAutoScroll = this.state.shouldAutoScroll 185 | if (element.scrollHeight - element.scrollTop === element.clientHeight) { 186 | shouldAutoScroll = true 187 | } else { 188 | shouldAutoScroll = false 189 | } 190 | this.setState({ 191 | shouldAutoScroll: shouldAutoScroll 192 | }) 193 | } 194 | 195 | hideModals () { 196 | if (this.props.emojiPickerVisible) { 197 | this.props.hideEmojiPicker() 198 | } 199 | } 200 | 201 | scrollToBottom (force) { 202 | if (!force && !this.state.shouldAutoScroll) return 203 | this.setState({ 204 | shouldAutoScroll: true 205 | }) 206 | var refScrollContainer = document.querySelector('.window__main') 207 | if (refScrollContainer) { 208 | refScrollContainer.scrollTop = refScrollContainer.scrollHeight 209 | } 210 | } 211 | 212 | showCabalSettings (addr) { 213 | this.props.showCabalSettings({ addr }) 214 | } 215 | 216 | showChannelPanel (addr) { 217 | this.props.showChannelPanel({ addr }) 218 | } 219 | 220 | render () { 221 | const { cabal, channelMemberCount, settings } = this.props 222 | var self = this 223 | 224 | if (!cabal) { 225 | return ( 226 | <> 227 |
228 | 229 | ) 230 | } else if (this.props.channelBrowserVisible) { 231 | return 232 | } else if (this.props.cabalSettingsVisible) { 233 | return 234 | } 235 | 236 | const isFavoriteChannel = settings['favorite-channels'] && settings['favorite-channels'].includes(cabal.channel) 237 | 238 | function getChannelName () { 239 | const userKey = Object.keys(cabal.users).find((key) => key === cabal.channel) 240 | const pmChannelName = cabal.users[userKey]?.name ?? cabal.channel.slice(0, 8) 241 | return cabal.isChannelPrivate ? pmChannelName : cabal.channel 242 | } 243 | 244 | const channelName = getChannelName() 245 | return ( 246 |
247 |
248 |
249 |
250 |
251 |
252 |

253 | {channelName} 254 | 259 | {isFavoriteChannel && } 260 | {!isFavoriteChannel && } 261 | 262 |

263 |

264 | {cabal.isChannelPrivate && ( 265 | 266 | 🔒 Private message with {channelName} 267 | 268 | )} 269 | {!cabal.isChannelPrivate && ( 270 | <> 271 |
272 | 273 |
277 | {channelMemberCount} 278 |
279 |
280 | 285 | {cabal.topic || 'Add a topic'} 286 | 287 | 288 | )} 289 |

290 |
291 |
292 | {!cabal.isChannelPrivate && ( 293 |
294 |
299 | 300 |
301 |
302 | )} 303 |
304 |
305 |
{ 308 | this.removeEventListeners() 309 | this.refScrollContainer = el 310 | }} 311 | > 312 | 313 |
314 | 318 |
319 |
320 | ) 321 | } 322 | } 323 | 324 | export default connect(mapStateToProps, mapDispatchToProps)(MainPanel) 325 | -------------------------------------------------------------------------------- /app/containers/write.js: -------------------------------------------------------------------------------- 1 | import form from 'get-form-data' 2 | import React, { Component } from 'react' 3 | import { connect } from 'react-redux' 4 | import Mousetrap from 'mousetrap' 5 | 6 | import { 7 | hideEmojiPicker, 8 | listCommands, 9 | processLine, 10 | setScreenViewHistoryPostion, 11 | showEmojiPicker, 12 | viewCabal, 13 | viewChannel, 14 | viewNextChannel, 15 | viewPreviousChannel 16 | } from '../actions' 17 | 18 | import '../../node_modules/emoji-mart/css/emoji-mart.css' 19 | import { Picker } from 'emoji-mart' 20 | 21 | const mapStateToProps = state => { 22 | var cabal = state.cabals[state.currentCabal] 23 | return { 24 | addr: state.currentCabal, 25 | cabal, 26 | cabalIdList: Object.keys(state.cabals).sort() || [], 27 | currentChannel: state.currentChannel, 28 | emojiPickerVisible: state.emojiPickerVisible, 29 | screenViewHistory: state.screenViewHistory, 30 | screenViewHistoryPosition: state.screenViewHistoryPosition, 31 | users: cabal.users 32 | } 33 | } 34 | 35 | const mapDispatchToProps = dispatch => ({ 36 | hideEmojiPicker: () => dispatch(hideEmojiPicker()), 37 | listCommands: () => dispatch(listCommands()), 38 | processLine: ({ addr, message }) => dispatch(processLine({ addr, message })), 39 | setScreenViewHistoryPostion: ({ index }) => dispatch(setScreenViewHistoryPostion({ index })), 40 | showEmojiPicker: () => dispatch(showEmojiPicker()), 41 | viewCabal: ({ addr, skipScreenHistory }) => dispatch(viewCabal({ addr, skipScreenHistory })), 42 | viewChannel: ({ addr, channel, skipScreenHistory }) => dispatch(viewChannel({ addr, channel, skipScreenHistory })), 43 | viewNextChannel: ({ addr }) => dispatch(viewNextChannel({ addr })), 44 | viewPreviousChannel: ({ addr }) => dispatch(viewPreviousChannel({ addr })) 45 | }) 46 | 47 | class writeScreen extends Component { 48 | constructor (props) { 49 | super(props) 50 | this.minimumHeight = 48 51 | this.defaultHeight = 17 + this.minimumHeight 52 | this.focusInput = this.focusInput.bind(this) 53 | this.clearInput = this.clearInput.bind(this) 54 | this.resizeTextInput = this.resizeTextInput.bind(this) 55 | this.addEmoji = this.addEmoji.bind(this) 56 | Mousetrap.bind(['command+left', 'ctrl+left'], this.viewPreviousScreen.bind(this)) 57 | Mousetrap.bind(['command+right', 'ctrl+right'], this.viewNextScreen.bind(this)) 58 | Mousetrap.bind(['command+n', 'ctrl+n'], this.viewNextChannel.bind(this)) 59 | Mousetrap.bind(['command+p', 'ctrl+p'], this.viewPreviousChannel.bind(this)) 60 | Mousetrap.bind(['command+shift+n', 'ctrl+shift+n'], this.goToNextCabal.bind(this)) 61 | Mousetrap.bind(['command+shift+p', 'ctrl+shift+p'], this.goToPreviousCabal.bind(this)) 62 | Mousetrap.bind(['command+up', 'ctrl+up'], this.goToPreviousCabal.bind(this)) 63 | Mousetrap.bind(['command+down', 'ctrl+down'], this.goToNextCabal.bind(this)) 64 | for (let i = 1; i < 10; i++) { 65 | Mousetrap.bind([`command+${i}`, `ctrl+${i}`], this.gotoCabal.bind(this, i)) 66 | } 67 | } 68 | 69 | componentDidMount () { 70 | this.focusInput() 71 | this.resizeTextInput() 72 | window.addEventListener('focus', (e) => this.focusInput()) 73 | } 74 | 75 | gotoCabal (index) { 76 | const { cabalIdList, viewCabal } = this.props 77 | if (cabalIdList[index]) { 78 | viewCabal({ addr: cabalIdList[index] }) 79 | } 80 | } 81 | 82 | goToPreviousCabal () { 83 | const { cabalIdList, addr: currentCabal, viewCabal } = this.props 84 | const currentIndex = cabalIdList.findIndex(i => i === currentCabal) 85 | const gotoIndex = currentIndex > 0 ? currentIndex - 1 : cabalIdList.length - 1 86 | viewCabal({ addr: cabalIdList[gotoIndex] }) 87 | } 88 | 89 | // go to the next cabal 90 | goToNextCabal () { 91 | const { cabalIdList, addr: currentCabal, viewCabal } = this.props 92 | const currentIndex = cabalIdList.findIndex(i => i === currentCabal) 93 | const gotoIndex = currentIndex < cabalIdList.length - 1 ? currentIndex + 1 : 0 94 | viewCabal({ addr: cabalIdList[gotoIndex] }) 95 | } 96 | 97 | componentWillUnmount () { 98 | window.removeEventListener('focus', (e) => this.focusInput()) 99 | } 100 | 101 | componentDidUpdate (prevProps) { 102 | if (this.props.currentChannel !== prevProps.currentChannel) { 103 | this.focusInput() 104 | } 105 | } 106 | 107 | viewNextScreen () { 108 | const position = this.props.screenViewHistoryPosition + 1 109 | const nextScreen = this.props.screenViewHistory[position] 110 | if (nextScreen) { 111 | if (this.props.addr === nextScreen.addr) { 112 | this.props.viewChannel({ addr: nextScreen.addr, channel: nextScreen.channel, skipScreenHistory: true }) 113 | } else { 114 | this.props.viewCabal({ addr: nextScreen.addr, channel: nextScreen.channel, skipScreenHistory: true }) 115 | } 116 | this.props.setScreenViewHistoryPostion({ index: position }) 117 | } else { 118 | this.props.setScreenViewHistoryPostion({ 119 | index: this.props.screenViewHistory.length - 1 120 | }) 121 | } 122 | } 123 | 124 | viewPreviousScreen () { 125 | const position = this.props.screenViewHistoryPosition - 1 126 | const previousScreen = this.props.screenViewHistory[position] 127 | if (previousScreen) { 128 | if (this.props.addr === previousScreen.addr) { 129 | this.props.viewChannel({ addr: previousScreen.addr, channel: previousScreen.channel, skipScreenHistory: true }) 130 | } else { 131 | this.props.viewCabal({ addr: previousScreen.addr, channel: previousScreen.channel, skipScreenHistory: true }) 132 | } 133 | this.props.setScreenViewHistoryPostion({ index: position }) 134 | } else { 135 | this.props.setScreenViewHistoryPostion({ 136 | index: 0 137 | }) 138 | } 139 | } 140 | 141 | viewNextChannel () { 142 | this.props.viewNextChannel({ addr: this.props.addr }) 143 | } 144 | 145 | viewPreviousChannel () { 146 | this.props.viewPreviousChannel({ addr: this.props.addr }) 147 | } 148 | 149 | onKeyDown (e) { 150 | var el = this.textInput 151 | var line = el.value 152 | if (e.key === 'Tab') { 153 | if (line.length > 1 && line[0] === '/') { 154 | // command completion 155 | var soFar = line.slice(1) 156 | var commands = Object.keys(this.props.listCommands()) 157 | var matchingCommands = commands.filter(cmd => cmd.startsWith(soFar)) 158 | if (matchingCommands.length === 1) { 159 | el.value = '/' + matchingCommands[0] + ' ' 160 | } 161 | } else { 162 | // nick completion 163 | var users = Object.keys(this.props.users) 164 | .map(key => this.props.users[key]) 165 | .map(user => user.name || user.key.substring(0, 6)) 166 | .sort() 167 | var pattern = (/^(\w+)$/) 168 | var match = pattern.exec(line) 169 | if (match) { 170 | users = users.filter(user => user.startsWith(match[0])) 171 | if (users.length > 0) el.value = users[0] + ': ' 172 | } 173 | } 174 | e.preventDefault() 175 | e.stopPropagation() 176 | el.focus() 177 | } else if (e.keyCode === 13 && e.shiftKey) { 178 | const cursorPosition = this.textInput.selectionStart 179 | const beforeCursor = this.textInput.value.slice(0, cursorPosition) 180 | const afterCursor = this.textInput.value.slice(cursorPosition) 181 | this.textInput.value = beforeCursor + '\n' + afterCursor 182 | this.textInput.setSelectionRange(cursorPosition + 1, cursorPosition + 1) 183 | e.preventDefault() 184 | e.stopPropagation() 185 | } else if (e.keyCode === 13 && !e.shiftKey) { 186 | const data = form(this.formField) 187 | if (data.message.trim().length === 0) { 188 | e.preventDefault() 189 | e.stopPropagation() 190 | return 191 | } 192 | el = this.textInput 193 | el.value = '' 194 | const { addr, processLine } = this.props 195 | const message = { 196 | content: { 197 | channel: this.props.currentChannel, 198 | text: data.message 199 | }, 200 | type: 'chat/text' 201 | } 202 | console.log('---> sending message', message) 203 | processLine({ addr, message }) 204 | e.preventDefault() 205 | e.stopPropagation() 206 | const { scrollToBottom } = this.props 207 | scrollToBottom(true) 208 | } else if (((e.keyCode === 78 || e.keyCode === 38) && (e.ctrlKey || e.metaKey)) && e.shiftKey) { 209 | if (line.length === 0) { 210 | this.goToNextCabal() 211 | } 212 | } else if (((e.keyCode === 80 || e.keyCode === 40) && (e.ctrlKey || e.metaKey)) && e.shiftKey) { 213 | if (line.length === 0) { 214 | this.goToPreviousCabal() 215 | } 216 | } else if (e.keyCode > 48 && e.keyCode < 58 && (e.ctrlKey || e.metaKey)) { 217 | this.gotoCabal(e.keyCode - 49) 218 | } else if ((e.keyCode === 78 && (e.ctrlKey || e.metaKey))) { 219 | this.viewNextChannel() 220 | } else if ((e.keyCode === 80 && (e.ctrlKey || e.metaKey))) { 221 | this.viewPreviousChannel() 222 | } else if ((e.keyCode === 39 && (e.ctrlKey || e.metaKey))) { 223 | if (line.length === 0) { 224 | this.viewNextScreen() 225 | } 226 | } else if ((e.keyCode === 37 && (e.ctrlKey || e.metaKey))) { 227 | if (line.length === 0) { 228 | this.viewPreviousScreen() 229 | } 230 | } 231 | } 232 | 233 | onClickEmojiPickerContainer (e) { 234 | const element = e.target 235 | // allow click event on emoji buttons but not other emoji picker ui 236 | if (!element.classList.contains('emoji-mart-emoji') && !element.parentElement.classList.contains('emoji-mart-emoji')) { 237 | e.preventDefault() 238 | e.stopPropagation() 239 | } 240 | } 241 | 242 | onsubmit (e) { 243 | // only prevent default keydown now handles logic to better support shift commands 244 | e.preventDefault() 245 | e.stopPropagation() 246 | } 247 | 248 | addEmoji (emoji) { 249 | this.textInput.value = this.textInput.value + emoji.native 250 | this.resizeTextInput() 251 | this.focusInput() 252 | } 253 | 254 | resizeTextInput () { 255 | this.textInput.style.height = '10px' 256 | this.textInput.style.height = (this.textInput.scrollHeight) + 'px' 257 | if (this.textInput.scrollHeight < 28) { 258 | this.emojiPicker.style.bottom = (68) + 'px' 259 | } else { 260 | this.emojiPicker.style.bottom = (this.textInput.scrollHeight + 40) + 'px' 261 | } 262 | } 263 | 264 | toggleEmojiPicker () { 265 | this.props.emojiPickerVisible ? this.props.hideEmojiPicker() : this.props.showEmojiPicker() 266 | } 267 | 268 | focusInput () { 269 | if (this.textInput) { 270 | this.textInput.focus() 271 | } 272 | } 273 | 274 | clearInput () { 275 | this.textInput.value = '' 276 | } 277 | 278 | render () { 279 | const { cabal, showScrollToBottom = true, scrollToBottom } = this.props 280 | if (!cabal) { 281 | return
282 | } 283 | return ( 284 |
285 | {showScrollToBottom && ( 286 |
287 | Newer messages below. Jump to latest ↓ 288 |
)} 289 |
290 | {/*
*/} 291 |
this.focusInput()}> 292 |
{ this.formField = form }} 295 | > 296 |