├── .babelrc ├── .env-sample ├── .github └── FUNDING.yml ├── .gitignore ├── .travis.yml ├── .vscode └── launch.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── app ├── actions.js ├── app.js ├── containers │ ├── addCabal.js │ ├── appSettings.js │ ├── avatar.js │ ├── cabalSettings.js │ ├── cabalsList.js │ ├── channelBrowser.js │ ├── channelPanel.js │ ├── customLink.js │ ├── dialog.js │ ├── empty.js │ ├── layout.js │ ├── mainPanel.js │ ├── memberList.js │ ├── messages.js │ ├── profilePanel.js │ ├── sidebar.js │ └── write.js ├── index.js ├── platform.js ├── reducer.js ├── selectors.js ├── settings.js ├── styles │ ├── darkmode.scss │ ├── react-contexify.css │ └── style.scss └── updater.js ├── bin └── build-multi ├── build ├── entitlements.mac.plist └── icon.icns ├── index.html ├── index.js ├── package.json ├── screenshot.png ├── scripts └── notarize.js ├── static ├── fonts │ └── Noto_Sans_JP │ │ ├── NotoSansJP-Black.otf │ │ ├── NotoSansJP-Bold.otf │ │ ├── NotoSansJP-Light.otf │ │ ├── NotoSansJP-Medium.otf │ │ ├── NotoSansJP-Regular.otf │ │ ├── NotoSansJP-Thin.otf │ │ └── OFL.txt └── images │ ├── cabal-desktop-dmg-background.jpg │ ├── cabal-desktop-dmg-background@2x.jpg │ ├── cabal-logo-black.svg │ ├── cabal-logo-white.svg │ ├── dmg-background.tiff │ ├── icon-addcabal.svg │ ├── icon-channel.svg │ ├── icon-channelother.svg │ ├── icon-composermeta.svg │ ├── icon-composerother.svg │ ├── icon-gear.svg │ ├── icon-newchannel.svg │ ├── icon-sidebarmenu.svg │ ├── icon-status-offline.svg │ ├── icon-status-online.svg │ └── user-icon.svg ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/env", 4 | "@babel/react" 5 | ], 6 | "plugins": ["@babel/plugin-transform-runtime", "@babel/plugin-proposal-optional-chaining"] 7 | } -------------------------------------------------------------------------------- /.env-sample: -------------------------------------------------------------------------------- 1 | APPLEID= 2 | APPLEIDPASS= 3 | ASCPROVIDER= 4 | BUNDLEID= 5 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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). -------------------------------------------------------------------------------- /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/actions.js: -------------------------------------------------------------------------------- 1 | import { homedir } from 'os' 2 | import { ipcRenderer } from 'electron' 3 | import Client from 'cabal-client' 4 | import CustomLink from './containers/customLink' 5 | import fs from 'fs' 6 | import githubSanitize from 'hast-util-sanitize/lib/github' 7 | import merge from 'deepmerge' 8 | import path from 'path' 9 | import remark from 'remark' 10 | import remarkAltProt from 'remark-altprot' 11 | import remarkEmoji from 'remark-emoji' 12 | import remarkReact from 'remark-react' 13 | import { throttle } from 'lodash' 14 | import User from 'cabal-client/src/user' 15 | 16 | const { dialog } = require('electron').remote 17 | 18 | const cabalComponents = { 19 | remarkReactComponents: { 20 | a: CustomLink 21 | } 22 | } 23 | 24 | const cabalSanitize = { 25 | sanitize: merge(githubSanitize, { protocols: { href: ['hyper', 'dat', 'cabal', 'hypergraph', 'hypermerge'] } }) 26 | } 27 | 28 | const DEFAULT_CHANNEL = 'default' 29 | const HOME_DIR = homedir() 30 | const DATA_DIR = path.join(HOME_DIR, '.cabal-desktop', `v${Client.getDatabaseVersion()}`) 31 | const STATE_FILE = path.join(DATA_DIR, 'cabals.json') 32 | const DEFAULT_PAGE_SIZE = 100 33 | const MAX_FEEDS = 1000 34 | 35 | const client = new Client({ 36 | maxFeeds: MAX_FEEDS, 37 | config: { 38 | dbdir: DATA_DIR 39 | }, 40 | commands: { 41 | help: { 42 | help: () => 'display this help message', 43 | call: (cabal, res, arg) => { 44 | const commands = client.getCommands() 45 | let helpContent = '' 46 | for (var key in commands) { 47 | helpContent = helpContent + `/${key} - ${commands[key].help()} \n` 48 | } 49 | addStatusMessage({ addr: cabal.key, text: helpContent }) 50 | } 51 | } 52 | } 53 | }) 54 | // Disable a few slash commands for now 55 | // TODO: figure out why cabal-client's removeCommand doesn't work? 56 | // tracked by: https://github.com/cabal-club/cabal-desktop/issues/306 57 | // const removedCommands = ['add', 'channels', 'clear', 'ids', 'names', 'new', 'qr', 'whoami', 'whois'] 58 | // removedCommands.forEach((command) => { 59 | // client.removeCommand(command) 60 | // }) 61 | 62 | // On exit, close the cabals to cleanly leave the hyperswarms 63 | window.onbeforeunload = (e) => { 64 | for (const cabal of client.cabals.values()) { 65 | cabal._destroy(() => {}) 66 | } 67 | } 68 | 69 | export const viewCabal = ({ addr, channel, skipScreenHistory }) => dispatch => { 70 | client.focusCabal(addr) 71 | channel = channel || client.getCurrentChannel() 72 | dispatch({ addr, channel, type: 'VIEW_CABAL' }) 73 | dispatch(viewChannel({ addr, channel, skipScreenHistory })) 74 | } 75 | 76 | export const showProfilePanel = ({ addr, userKey }) => (dispatch) => { 77 | dispatch(hideChannelPanel({ addr })) 78 | dispatch({ type: 'SHOW_PROFILE_PANEL', addr, userKey }) 79 | } 80 | 81 | export const hideProfilePanel = ({ addr }) => (dispatch) => { 82 | dispatch({ type: 'HIDE_PROFILE_PANEL', addr }) 83 | } 84 | 85 | export const showChannelPanel = ({ addr }) => (dispatch) => { 86 | dispatch(hideProfilePanel({ addr })) 87 | dispatch({ type: 'SHOW_CHANNEL_PANEL', addr }) 88 | } 89 | 90 | export const hideChannelPanel = ({ addr }) => (dispatch) => { 91 | dispatch({ type: 'HIDE_CHANNEL_PANEL', addr }) 92 | } 93 | 94 | export const updateScreenViewHistory = ({ addr, channel }) => (dispatch) => { 95 | dispatch({ type: 'UPDATE_SCREEN_VIEW_HISTORY', addr, channel }) 96 | } 97 | 98 | export const setScreenViewHistoryPostion = ({ index }) => (dispatch) => { 99 | dispatch({ type: 'SET_SCREEN_VIEW_HISTORY_POSITION', index }) 100 | } 101 | 102 | export const showChannelBrowser = ({ addr }) => dispatch => { 103 | const cabalDetails = client.getDetails(addr) 104 | const channelsData = Object.values(cabalDetails.channels).filter((channel) => { 105 | // Omit private message channels 106 | return !channel.isPrivate 107 | }).map((channel) => { 108 | return { 109 | ...channel, 110 | memberCount: channel.members.size 111 | } 112 | }) 113 | dispatch({ type: 'UPDATE_CHANNEL_BROWSER', addr, channelsData }) 114 | dispatch(hideAllModals()) 115 | dispatch({ type: 'SHOW_CHANNEL_BROWSER', addr }) 116 | } 117 | 118 | export const showCabalSettings = ({ addr }) => dispatch => { 119 | dispatch(hideAllModals()) 120 | dispatch({ type: 'SHOW_CABAL_SETTINGS', addr }) 121 | } 122 | 123 | export const hideCabalSettings = () => dispatch => { 124 | dispatch({ type: 'HIDE_CABAL_SETTINGS' }) 125 | } 126 | 127 | export const hideAllModals = () => dispatch => { 128 | dispatch({ type: 'HIDE_ALL_MODALS' }) 129 | } 130 | 131 | export const restoreCabalSettings = ({ addr, settings }) => dispatch => { 132 | dispatch({ type: 'UPDATE_CABAL_SETTINGS', addr, settings }) 133 | } 134 | 135 | export const saveCabalSettings = ({ addr, settings }) => dispatch => { 136 | dispatch({ type: 'UPDATE_CABAL_SETTINGS', addr, settings }) 137 | dispatch(storeOnDisk()) 138 | } 139 | 140 | export const removeCabal = ({ addr }) => dispatch => { 141 | dialog.showMessageBox({ 142 | type: 'question', 143 | buttons: ['Cancel', 'Remove'], 144 | message: `Are you sure you want to remove this cabal (${addr.substr(0, 8)}...) from Cabal Desktop?` 145 | }).then((response) => { 146 | if (response.response === 1) { 147 | dispatch(confirmRemoveCabal({ addr })) 148 | } 149 | }) 150 | } 151 | 152 | // remove cabal 153 | export const confirmRemoveCabal = ({ addr }) => async dispatch => { 154 | client.removeCabal(addr) 155 | // dispatch({ type: 'DELETE_CABAL', addr }) 156 | // update the local file to reflect while restarting the app 157 | dispatch(storeOnDisk()) 158 | const allCabals = client.getCabalKeys() 159 | 160 | // switch to the first cabal, else in case of no remaning cabals 161 | // show the add-cabal screen 162 | if (allCabals.length) { 163 | const toCabal = allCabals[0] 164 | client.focusCabal(toCabal) 165 | const cabalDetails = client.getDetails(toCabal) 166 | dispatch({ 167 | addr: toCabal, 168 | channel: cabalDetails.getCurrentChannel(), 169 | type: 'VIEW_CABAL' 170 | }) 171 | } else { 172 | dispatch({ type: 'CHANGE_SCREEN', screen: 'addCabal' }) 173 | } 174 | dispatch(hideAllModals()) 175 | } 176 | 177 | export const listCommands = () => dispatch => { 178 | return client.getCommands() 179 | } 180 | 181 | export const joinChannel = ({ addr, channel }) => dispatch => { 182 | if (channel.length > 0) { 183 | const cabalDetails = client.getDetails(addr) 184 | // Catch new private message channels 185 | const users = cabalDetails.getUsers() 186 | const user = users[channel] 187 | const pmChannels = cabalDetails.getPrivateMessageList() 188 | const isNewPmChannel = user && !pmChannels.includes(channel) 189 | if (isNewPmChannel) { 190 | dispatch(hideAllModals()) 191 | dispatch({ type: 'VIEW_CABAL', addr, channel }) 192 | dispatch({ type: 'UPDATE_CABAL', addr, channel, messages: [], isChannelPrivate: true }) 193 | } else { 194 | cabalDetails.joinChannel(channel) 195 | dispatch(addChannel({ addr, channel })) 196 | dispatch(viewChannel({ addr, channel })) 197 | } 198 | } 199 | } 200 | 201 | export const confirmArchiveChannel = ({ addr, channel }) => dispatch => { 202 | dialog.showMessageBox({ 203 | type: 'question', 204 | buttons: ['Cancel', 'Archive'], 205 | message: `Are you sure you want to archive this channel, ${channel}?` 206 | }).then((response) => { 207 | if (response.response === 1) { 208 | dispatch(archiveChannel({ addr })) 209 | } 210 | }) 211 | } 212 | 213 | export const archiveChannel = ({ addr, channel }) => dispatch => { 214 | const currentChannel = client.getCurrentChannel() 215 | if (!channel || !channel.length) { 216 | channel = currentChannel 217 | } 218 | if (channel === currentChannel) { 219 | dispatch(viewNextChannel({ addr })) 220 | } 221 | const cabalDetails = client.getDetails(addr) 222 | cabalDetails.leaveChannel(channel) 223 | cabalDetails.archiveChannel(channel) 224 | const channels = cabalDetails.getChannels({ includePM: false }) 225 | const pmChannels = cabalDetails.getPrivateMessageList() 226 | const channelsJoined = cabalDetails.getJoinedChannels() || [] 227 | const channelMessagesUnread = getCabalUnreadMessagesCount(cabalDetails) 228 | dispatch({ type: 'UPDATE_CABAL', initialized: true, addr, channelMessagesUnread, channels, channelsJoined, pmChannels }) 229 | } 230 | 231 | export const unarchiveChannel = ({ addr, channel }) => dispatch => { 232 | const currentChannel = client.getCurrentChannel() 233 | if (!channel || !channel.length) { 234 | channel = currentChannel 235 | } 236 | const cabalDetails = client.getDetails(addr) 237 | cabalDetails.unarchiveChannel(channel) 238 | } 239 | 240 | export const leaveChannel = ({ addr, channel }) => dispatch => { 241 | const currentChannel = client.getCurrentChannel() 242 | if (!channel || !channel.length) { 243 | channel = currentChannel 244 | } 245 | if (channel === currentChannel) { 246 | dispatch(viewNextChannel({ addr })) 247 | } 248 | const cabalDetails = client.getDetails(addr) 249 | cabalDetails.leaveChannel(channel) 250 | } 251 | 252 | export const viewNextChannel = ({ addr }) => dispatch => { 253 | const cabalDetails = client.getDetails(addr) 254 | const channels = cabalDetails.getJoinedChannels() 255 | if (channels.length) { 256 | let index = channels.findIndex((channel) => channel === client.getCurrentChannel()) + 1 257 | if (index > channels.length - 1) { 258 | index = 0 259 | } 260 | dispatch(viewChannel({ addr, channel: channels[index] })) 261 | } 262 | } 263 | 264 | export const viewPreviousChannel = ({ addr }) => dispatch => { 265 | const cabalDetails = client.getDetails(addr) 266 | const channels = cabalDetails.getJoinedChannels() 267 | if (channels.length) { 268 | let index = channels.findIndex((channel) => channel === client.getCurrentChannel()) - 1 269 | if (index < 0) { 270 | index = channels.length - 1 271 | } 272 | dispatch(viewChannel({ addr, channel: channels[index] })) 273 | } 274 | } 275 | 276 | export const setUsername = ({ username, addr }) => dispatch => { 277 | const cabalDetails = client.getDetails(addr) 278 | const currentUsername = cabalDetails.getLocalName() 279 | if (username !== currentUsername) { 280 | cabalDetails.publishNick(username, () => { 281 | dispatch({ type: 'UPDATE_CABAL', addr: cabalDetails.key, username }) 282 | addStatusMessage({ 283 | addr: cabalDetails.key, 284 | channel: cabalDetails.getCurrentChannel(), 285 | text: `Nick set to: ${username}` 286 | }) 287 | }) 288 | } 289 | } 290 | 291 | const enrichMessage = (message) => { 292 | return Object.assign({}, message, { 293 | enriched: { 294 | time: message.time, 295 | content: remark() 296 | .use(remarkAltProt) 297 | .use(remarkReact, { ...cabalSanitize, ...cabalComponents }) 298 | .use(remarkEmoji).processSync(message.content) 299 | .result 300 | } 301 | }) 302 | } 303 | 304 | export const getMessages = ({ addr, channel, amount }, callback) => dispatch => { 305 | client.focusCabal(addr) 306 | if (client.getChannels().includes(channel)) { 307 | client.getMessages({ amount, channel }, (messages) => { 308 | messages = messages.map((message) => { 309 | const user = dispatch(getUser({ key: message.key })) 310 | const { type, timestamp, content } = message.value 311 | return enrichMessage({ 312 | content: content && content.text, 313 | key: message.key, 314 | message, 315 | time: timestamp, 316 | type, 317 | user 318 | }) 319 | }) 320 | dispatch({ type: 'UPDATE_CABAL', addr, messages }) 321 | if (callback) { 322 | callback(messages) 323 | } 324 | }) 325 | } 326 | } 327 | 328 | export const onIncomingMessage = ({ addr, channel, message }, callback) => (dispatch, getState) => { 329 | const cabalDetails = client.getDetails(addr) 330 | const cabalKey = client.getCurrentCabal().key 331 | const currentChannel = cabalDetails.getCurrentChannel() 332 | const pmChannels = cabalDetails.getPrivateMessageList() 333 | 334 | // Ignore incoming message from channels you're not in 335 | if (!message.value.private && !cabalDetails.getJoinedChannels().includes(channel)) { 336 | return 337 | } 338 | 339 | const user = dispatch(getUser({ key: message.key })) 340 | 341 | // Ignore incoming messages from hidden users 342 | if (user && user.isHidden()) return 343 | 344 | // Add incoming message to message list if you're viewing that channel 345 | if ((channel === currentChannel) && (addr === cabalKey)) { 346 | const { type, timestamp, content } = message.value 347 | const enrichedMessage = enrichMessage({ 348 | content: content && content.text, 349 | key: message.key, 350 | message, 351 | time: timestamp, 352 | type, 353 | user 354 | }) 355 | const messages = [ 356 | ...getState()?.cabals[addr].messages, 357 | enrichedMessage 358 | ] 359 | dispatch({ type: 'UPDATE_CABAL', addr, messages, pmChannels }) 360 | } else { 361 | if (message.value.private === (addr === cabalKey)) { 362 | dispatch({ type: 'UPDATE_CABAL', addr, pmChannels }) 363 | } 364 | // Skip adding to message list if not viewing that channel, instead update unread count 365 | dispatch(updateUnreadCounts({ addr })) 366 | } 367 | 368 | const settings = getState().cabalSettings[addr] 369 | if (!!settings.enableNotifications && !document.hasFocus()) { 370 | dispatch(sendDesktopNotification({ 371 | addr, 372 | user, 373 | channel, 374 | content: message.value.content 375 | })) 376 | } 377 | } 378 | 379 | export const getUsers = () => (dispatch) => { 380 | const cabalDetails = client.getCurrentCabal() 381 | return cabalDetails.getUsers() 382 | } 383 | 384 | export const getUser = ({ key }) => (dispatch) => { 385 | const cabalDetails = client.getCurrentCabal() 386 | const users = cabalDetails.getUsers() 387 | // TODO: This should be inside cabalDetails.getUser(...) 388 | var user = users[key] 389 | if (!user) { 390 | user = new User({ 391 | name: key.substr(0, 6), 392 | key: key 393 | }) 394 | } 395 | if (!user.name) user.name = key.substr(0, 6) 396 | 397 | return user 398 | } 399 | 400 | export const viewChannel = ({ addr, channel, skipScreenHistory }) => (dispatch, getState) => { 401 | if (!channel || channel.length === 0) return 402 | 403 | if (client.getChannels().includes(channel)) { 404 | client.focusChannel(channel) 405 | client.markChannelRead(channel) 406 | } else { 407 | // TODO: After the lastest cabal-client update, this line which throws the app into a loading loop. 408 | // But, it seems that joinChannel may not be needed here as things seem to work as expected without it. 409 | // Next step: investigate why this loops and if there's regression from removing this line: 410 | // dispatch(joinChannel({ addr, channel })) 411 | } 412 | 413 | const cabalDetails = client.getCurrentCabal() 414 | const channelMessagesUnread = getCabalUnreadMessagesCount(cabalDetails) 415 | 416 | dispatch(hideAllModals()) 417 | dispatch({ 418 | addr, 419 | channel: cabalDetails.getCurrentChannel(), 420 | channelMessagesUnread, 421 | channels: cabalDetails.getChannels({ includePM: false }), 422 | channelsJoined: cabalDetails.getJoinedChannels(), 423 | isChannelPrivate: cabalDetails.isChannelPrivate(cabalDetails.getCurrentChannel()), 424 | pmChannels: cabalDetails.getPrivateMessageList(), 425 | type: 'ADD_CABAL', 426 | username: cabalDetails.getLocalName(), 427 | users: cabalDetails.getUsers() 428 | }) 429 | dispatch({ 430 | addr, 431 | channel: cabalDetails.getCurrentChannel(), 432 | type: 'VIEW_CABAL' 433 | }) 434 | dispatch(getMessages({ addr, channel, amount: 100 })) 435 | 436 | const topic = cabalDetails.getTopic() 437 | dispatch({ type: 'UPDATE_TOPIC', addr, topic }) 438 | dispatch(updateChannelMessagesUnread({ addr, channel, unreadCount: 0 })) 439 | 440 | // When a user is walking through history by using screen history navigation commands, 441 | // `skipScreenHistory=true` does not add that navigation event to the end of the history 442 | // stack so that navigating again forward through history works. 443 | if (!skipScreenHistory) { 444 | dispatch(updateScreenViewHistory({ addr, channel })) 445 | } 446 | 447 | dispatch(saveCabalSettings({ addr, settings: { currentChannel: channel } })) 448 | } 449 | 450 | export const changeScreen = ({ screen, addr }) => ({ type: 'CHANGE_SCREEN', screen, addr }) 451 | 452 | export const addCabal = ({ addr, isNewlyAdded, settings, username }) => async (dispatch) => { 453 | if (addr) { 454 | // Convert domain keys to cabal keys 455 | addr = await client.resolveName(addr) 456 | } 457 | if (client._keyToCabal[addr]) { 458 | // Show cabal if already added to client 459 | dispatch(viewCabal({ addr })) 460 | if (username) { 461 | dispatch(setUsername({ addr, username })) 462 | } 463 | } else { 464 | // Add the cabal to the client using the default per cabal user settings 465 | settings = { 466 | alias: '', 467 | enableNotifications: false, 468 | currentChannel: DEFAULT_CHANNEL, 469 | ...settings 470 | } 471 | dispatch(initializeCabal({ addr, isNewlyAdded, settings, username })) 472 | } 473 | } 474 | 475 | export const sendDesktopNotification = throttle(({ addr, user, channel, content }) => (dispatch) => { 476 | window.Notification.requestPermission() 477 | const notification = new window.Notification(user.name, { 478 | body: content.text 479 | }) 480 | notification.onclick = () => { 481 | dispatch(viewCabal({ addr, channel })) 482 | } 483 | }, 5000, { leading: true, trailing: true }) 484 | 485 | export const addChannel = ({ addr, channel }) => (dispatch, getState) => { 486 | dispatch(hideAllModals()) 487 | const cabalDetails = client.getCurrentCabal() 488 | 489 | client.focusChannel(channel) 490 | const topic = cabalDetails.getTopic() 491 | 492 | const opts = {} 493 | opts.newerThan = opts.newerThan || null 494 | opts.olderThan = opts.olderThan || Date.now() 495 | opts.amount = opts.amount || DEFAULT_PAGE_SIZE * 2.5 496 | 497 | client.getMessages(opts, (messages) => { 498 | messages = messages.map((message) => { 499 | const { type, timestamp, content = {} } = message.value 500 | const user = dispatch(getUser({ key: message.key })) 501 | 502 | const settings = getState().cabalSettings[addr] 503 | if (!!settings.enableNotifications && !document.hasFocus()) { 504 | dispatch(sendDesktopNotification({ addr, user, channel, content })) 505 | } 506 | 507 | return enrichMessage({ 508 | content: content.text, 509 | key: message.key, 510 | message, 511 | time: timestamp, 512 | type, 513 | user 514 | }) 515 | }) 516 | if (cabalDetails.getCurrentChannel() === channel) { 517 | dispatch({ type: 'UPDATE_CABAL', addr, messages, topic }) 518 | } 519 | }) 520 | } 521 | 522 | export const processLine = ({ message, addr }) => dispatch => { 523 | const channel = message.content.channel 524 | const cabalDetails = client.getDetails(addr) 525 | const users = cabalDetails.getUsers() 526 | const pmChannels = cabalDetails.getPrivateMessageList() 527 | const isNewPmChannel = users[channel] && !pmChannels.includes(channel) 528 | 529 | const text = message.content.text 530 | if (text?.startsWith('/')) { 531 | const cabal = client.getCurrentCabal() 532 | cabal.processLine(text) 533 | } else { 534 | if (isNewPmChannel) { 535 | // this creates the channel by sending a private message (PMs are not initiated until posting a message) 536 | cabalDetails.publishPrivateMessage(message, channel) 537 | dispatch(joinChannel({ addr, channel })) 538 | } else { 539 | cabalDetails.publishMessage(message) 540 | } 541 | } 542 | } 543 | 544 | export const addStatusMessage = ({ addr, channel, text }) => { 545 | const cabalDetails = addr ? client.getDetails(addr) : client.getCurrentCabal() 546 | client.addStatusMessage({ text }, channel, cabalDetails._cabal) 547 | } 548 | 549 | export const setChannelTopic = ({ topic, channel, addr }) => dispatch => { 550 | const cabalDetails = client.getDetails(addr) 551 | cabalDetails.publishChannelTopic(channel, topic) 552 | dispatch({ type: 'UPDATE_TOPIC', addr, topic }) 553 | addStatusMessage({ 554 | addr, 555 | channel, 556 | text: `Topic set to: ${topic}` 557 | }) 558 | } 559 | 560 | export const updateChannelMessagesUnread = ({ addr, channel, unreadCount }) => (dispatch, getState) => { 561 | const cabals = getState().cabals || {} 562 | const cabal = cabals[addr] || {} 563 | const channelMessagesUnread = getState().cabals[addr].channelMessagesUnread || {} 564 | if (unreadCount !== undefined) { 565 | channelMessagesUnread[channel] = unreadCount 566 | } else { 567 | channelMessagesUnread[channel] = (cabal.channelMessagesUnread && cabal.channelMessagesUnread[channel]) || 0 568 | } 569 | dispatch({ type: 'UPDATE_CABAL', addr, channelMessagesUnread }) 570 | dispatch(updateAllsChannelsUnreadCount({ addr, channelMessagesUnread })) 571 | } 572 | 573 | export const updateAllsChannelsUnreadCount = ({ addr, channelMessagesUnread }) => (dispatch, getState) => { 574 | const allChannelsUnreadCount = Object.values(channelMessagesUnread).reduce((total, value) => { 575 | return total + (value || 0) 576 | }, 0) 577 | if (allChannelsUnreadCount !== getState()?.cabals[addr]?.allChannelsUnreadCount) { 578 | dispatch({ type: 'UPDATE_CABAL', addr, allChannelsUnreadCount, channelMessagesUnread }) 579 | dispatch(updateAppIconBadge()) 580 | } 581 | } 582 | 583 | export const updateUnreadCounts = ({ addr }) => (dispatch) => { 584 | const cabalDetails = client.getDetails(addr) 585 | const channelMessagesUnread = getCabalUnreadMessagesCount(cabalDetails) 586 | dispatch(updateAllsChannelsUnreadCount({ addr, channelMessagesUnread })) 587 | } 588 | 589 | export const updateAppIconBadge = (badgeCount) => (dispatch, getState) => { 590 | // TODO: if (!!app.settings.enableBadgeCount) { 591 | const cabals = getState().cabals || {} 592 | badgeCount = badgeCount || Object.values(cabals).reduce((total, cabal) => { 593 | return total + (cabal.allChannelsUnreadCount || 0) 594 | }, 0) 595 | ipcRenderer.send('update-badge', { badgeCount, showCount: false }) // TODO: app.settings.showBadgeCountNumber 596 | dispatch({ type: 'UPDATE_WINDOW_BADGE', badgeCount }) 597 | } 598 | 599 | export const showEmojiPicker = () => dispatch => { 600 | dispatch({ type: 'SHOW_EMOJI_PICKER' }) 601 | } 602 | 603 | export const hideEmojiPicker = () => dispatch => { 604 | dispatch({ type: 'HIDE_EMOJI_PICKER' }) 605 | } 606 | 607 | const getCabalUnreadMessagesCount = (cabalDetails) => { 608 | const cabalCore = client._keyToCabal[cabalDetails.key] 609 | const channelMessagesUnread = {} 610 | // fetch unread message count only for joined channels. 611 | const channels = [...cabalDetails.getJoinedChannels(), ...cabalDetails.getPrivateMessageList()] 612 | channels.map((channel) => { 613 | channelMessagesUnread[channel] = client.getNumberUnreadMessages(channel, cabalCore) 614 | }) 615 | return channelMessagesUnread 616 | } 617 | 618 | const initializeCabal = ({ addr, isNewlyAdded, username, settings }) => async dispatch => { 619 | const isNew = !addr 620 | const cabalDetails = isNew ? await client.createCabal() : await client.addCabal(addr) 621 | addr = cabalDetails.key 622 | 623 | console.log('---> initializeCabal', { addr, settings }) 624 | 625 | function initialize () { 626 | const users = cabalDetails.getUsers() 627 | const userkey = cabalDetails.getLocalUser().key 628 | const username = cabalDetails.getLocalName() 629 | const channels = cabalDetails.getChannels({ includePM: false }) 630 | const pmChannels = cabalDetails.getPrivateMessageList() 631 | const channelsJoined = cabalDetails.getJoinedChannels() || [] 632 | const channelMessagesUnread = getCabalUnreadMessagesCount(cabalDetails) 633 | const currentChannel = cabalDetails.getCurrentChannel() 634 | const channelMembers = cabalDetails.getChannelMembers() 635 | dispatch({ type: 'UPDATE_CABAL', initialized: false, addr, channelMessagesUnread, users, userkey, username, channels, channelsJoined, currentChannel, channelMembers, pmChannels }) 636 | dispatch(getMessages({ addr, amount: 1000, channel: currentChannel })) 637 | dispatch(updateAllsChannelsUnreadCount({ addr, channelMessagesUnread })) 638 | client.focusCabal(addr) 639 | dispatch(viewCabal({ addr, channel: settings.currentChannel })) 640 | } 641 | 642 | const cabalDetailsEvents = [ 643 | { 644 | name: 'update', 645 | action: (data) => { 646 | // console.log('update event', data) 647 | } 648 | }, 649 | { 650 | name: 'cabal-focus', 651 | action: () => { } 652 | }, { 653 | name: 'command', 654 | action: (data) => { 655 | console.log('COMMAND', data) 656 | } 657 | }, { 658 | name: 'channel-focus', 659 | action: () => { 660 | const channelsJoined = cabalDetails.getJoinedChannels() 661 | const channelMembers = cabalDetails.getChannelMembers() 662 | const channelMessagesUnread = getCabalUnreadMessagesCount(cabalDetails) 663 | const currentChannel = cabalDetails.getCurrentChannel() 664 | const username = cabalDetails.getLocalName() 665 | const users = cabalDetails.getUsers() 666 | dispatch({ type: 'UPDATE_CABAL', addr, channelMembers, channelMessagesUnread, channelsJoined, currentChannel, username, users }) 667 | dispatch(updateAllsChannelsUnreadCount({ addr, channelMessagesUnread })) 668 | } 669 | }, { 670 | name: 'channel-join', 671 | action: () => { 672 | const channelMembers = cabalDetails.getChannelMembers() 673 | const channelMessagesUnread = getCabalUnreadMessagesCount(cabalDetails) 674 | const channelsJoined = cabalDetails.getJoinedChannels() 675 | const currentChannel = cabalDetails.getCurrentChannel() 676 | dispatch({ type: 'UPDATE_CABAL', addr, channelMembers, channelMessagesUnread, channelsJoined, currentChannel }) 677 | dispatch(getMessages({ addr, amount: 1000, channel: currentChannel })) 678 | dispatch(updateAllsChannelsUnreadCount({ addr, channelMessagesUnread })) 679 | dispatch(viewChannel({ addr, channel: currentChannel })) 680 | } 681 | }, { 682 | name: 'channel-leave', 683 | action: (data) => { 684 | const currentChannel = client.getCurrentChannel() 685 | const channelMessagesUnread = getCabalUnreadMessagesCount(cabalDetails) 686 | const channelsJoined = cabalDetails.getJoinedChannels() 687 | dispatch({ type: 'UPDATE_CABAL', addr, channelMessagesUnread, channelsJoined }) 688 | dispatch(updateAllsChannelsUnreadCount({ addr, channelMessagesUnread })) 689 | dispatch(viewChannel({ addr, channel: currentChannel })) 690 | } 691 | }, { 692 | name: 'command', 693 | action: ({ arg, command, data }) => { 694 | console.warn('command', { arg, command, data }) 695 | } 696 | }, { 697 | name: 'info', 698 | action: (info) => { 699 | console.log('info', info) 700 | if (info?.text?.startsWith('whispering on')) { 701 | const currentChannel = client.getCurrentChannel() 702 | client.addStatusMessage({ text: info.text }, currentChannel, cabalDetails._cabal) 703 | } 704 | } 705 | }, { 706 | name: 'init', 707 | action: initialize 708 | }, { 709 | name: 'new-channel', 710 | action: () => { 711 | const channels = cabalDetails.getChannels({ includePM: false }) 712 | const pmChannels = cabalDetails.getPrivateMessageList() 713 | const channelMembers = cabalDetails.getChannelMembers() 714 | dispatch({ type: 'UPDATE_CABAL', addr, channels, channelMembers, pmChannels }) 715 | } 716 | }, { 717 | name: 'new-message', 718 | throttleDelay: 500, 719 | action: (data) => { 720 | const channel = data.channel 721 | const message = data.message 722 | dispatch(onIncomingMessage({ addr, channel, message })) 723 | } 724 | }, { 725 | name: 'private-message', 726 | throttleDelay: 500, 727 | action: (data) => { 728 | console.log('private-message', data) 729 | } 730 | }, 731 | { 732 | name: 'publish-message', 733 | action: () => { 734 | // don't do anything on publish message (new-message takes care of it) 735 | } 736 | }, { 737 | name: 'publish-nick', 738 | action: () => { 739 | const users = cabalDetails.getUsers() 740 | dispatch({ type: 'UPDATE_CABAL', addr, users }) 741 | } 742 | }, { 743 | name: 'started-peering', 744 | throttleDelay: 1000, 745 | action: () => { 746 | const users = cabalDetails.getUsers() 747 | dispatch({ type: 'UPDATE_CABAL', addr, users }) 748 | } 749 | }, { 750 | name: 'status-message', 751 | action: () => { 752 | const channelMessagesUnread = getCabalUnreadMessagesCount(cabalDetails) 753 | const currentChannel = cabalDetails.getCurrentChannel() 754 | dispatch(getMessages({ addr, amount: 1000, channel: currentChannel })) 755 | dispatch(updateAllsChannelsUnreadCount({ addr, channelMessagesUnread })) 756 | } 757 | }, { 758 | name: 'stopped-peering', 759 | throttleDelay: 1000, 760 | action: () => { 761 | const users = cabalDetails.getUsers() 762 | dispatch({ type: 'UPDATE_CABAL', addr, users }) 763 | } 764 | }, { 765 | name: 'topic', 766 | action: (data) => { 767 | const cabal = client.getCurrentCabal() 768 | const channel = data.channel 769 | const topic = cabalDetails.getTopic() 770 | dispatch({ type: 'UPDATE_TOPIC', addr, topic }) 771 | if (addr === cabal.key && channel === cabalDetails.getCurrentChannel()) { 772 | addStatusMessage({ 773 | addr, 774 | channel, 775 | text: `Topic set to: ${topic}` 776 | }) 777 | } 778 | } 779 | }, { 780 | name: 'user-updated', 781 | action: (data) => { 782 | const users = cabalDetails.getUsers() 783 | dispatch({ type: 'UPDATE_CABAL', addr, users }) 784 | // Update local user 785 | const cabal = client.getCurrentCabal() 786 | if (data.key === cabal.getLocalUser().key) { 787 | const username = data.user?.name 788 | dispatch({ type: 'UPDATE_CABAL', addr: cabalDetails.key, username }) 789 | addStatusMessage({ 790 | addr: cabalDetails.key, 791 | channel: cabalDetails.getCurrentChannel(), 792 | text: `Nick set to: ${username}` 793 | }) 794 | } 795 | } 796 | } 797 | ] 798 | setTimeout(() => { 799 | cabalDetailsEvents.forEach((event) => { 800 | const action = throttle((data) => { 801 | // console.log('Event:', event.name, data) 802 | event.action(data) 803 | }, event.throttleDelay, { leading: true, trailing: true }) 804 | cabalDetails.on(event.name, action) 805 | }) 806 | initialize() 807 | dispatch({ type: 'UPDATE_CABAL', initialized: true, addr }) 808 | }, isNewlyAdded ? 10000 : 0) 809 | 810 | // if creating a new cabal, set a default username. 811 | if (isNew || username) { 812 | dispatch(setUsername({ username: username || generateUniqueName(), addr })) 813 | } 814 | } 815 | 816 | export const loadFromDisk = () => async dispatch => { 817 | let state 818 | try { 819 | state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')) 820 | } catch (_) { 821 | state = {} 822 | } 823 | const stateKeys = Object.keys(state) 824 | // Restore previous settings state into store before initializing cabals 825 | stateKeys.forEach((key) => { 826 | const { addr, settings } = JSON.parse(state[key]) 827 | dispatch(restoreCabalSettings({ addr, settings })) 828 | }) 829 | // Initialize all of the cabals 830 | stateKeys.forEach((key) => { 831 | const { addr, settings } = JSON.parse(state[key]) 832 | dispatch(addCabal({ addr, settings })) 833 | }) 834 | // if (stateKeys.length) { 835 | // setTimeout(() => { 836 | // const firstCabal = JSON.parse(state[stateKeys[0]]) 837 | // dispatch(viewCabal({ addr: firstCabal.addr, channel: firstCabal.settings.currentChannel })) 838 | // client.focusCabal(firstCabal.addr) 839 | // }, 5000) 840 | // } 841 | dispatch({ type: 'CHANGE_SCREEN', screen: stateKeys.length ? 'main' : 'addCabal' }) 842 | } 843 | 844 | const storeOnDisk = () => (dispatch, getState) => { 845 | const cabalKeys = client.getCabalKeys() 846 | const { cabalSettings } = getState() 847 | const state = {} 848 | cabalKeys.forEach((addr) => { 849 | state[addr] = JSON.stringify({ 850 | addr, 851 | settings: cabalSettings[addr] || {} 852 | }) 853 | }) 854 | fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)) 855 | } 856 | 857 | const generateUniqueName = () => { 858 | const adjectives = ['ancient', 'whispering', 'hidden', 'emerald', 'occult', 'obscure', 'wandering', 'ephemeral', 'eccentric', 'singing'] 859 | const nouns = ['lichen', 'moss', 'shadow', 'stone', 'ghost', 'friend', 'spore', 'fungi', 'mold', 'mountain', 'compost', 'conspirator'] 860 | 861 | const randomItem = (array) => array[Math.floor(Math.random() * array.length)] 862 | return `${randomItem(adjectives)}-${randomItem(nouns)}` 863 | } 864 | 865 | export const moderationHide = (props) => async dispatch => { 866 | dispatch(moderationAction('hide', props)) 867 | } 868 | 869 | export const moderationUnhide = (props) => async dispatch => { 870 | dispatch(moderationAction('unhide', props)) 871 | } 872 | 873 | export const moderationBlock = (props) => async dispatch => { 874 | dispatch(moderationAction('block', props)) 875 | } 876 | 877 | export const moderationUnblock = (props) => async dispatch => { 878 | dispatch(moderationAction('unblock', props)) 879 | } 880 | 881 | export const moderationAddMod = (props) => async dispatch => { 882 | dispatch(moderationAction('addMod', props)) 883 | } 884 | 885 | export const moderationRemoveMod = (props) => async dispatch => { 886 | dispatch(moderationAction('removeMod', props)) 887 | } 888 | 889 | export const moderationAddAdmin = (props) => async dispatch => { 890 | dispatch(moderationAction('addAdmin', props)) 891 | } 892 | 893 | export const moderationRemoveAdmin = (props) => async dispatch => { 894 | dispatch(moderationAction('removeAdmin', props)) 895 | } 896 | 897 | export const moderationAction = (action, { addr, channel, reason, userKey }) => async dispatch => { 898 | const cabalDetails = client.getDetails(addr) 899 | await cabalDetails.moderation[action](userKey, { channel, reason }) 900 | setTimeout(() => { 901 | const users = cabalDetails.getUsers() 902 | dispatch({ type: 'UPDATE_CABAL', addr, users }) 903 | }, 500) 904 | } 905 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/containers/empty.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cabal-club/cabal-desktop/e19cd37cac3b8f90447c70839d6cb5792628a503/app/containers/empty.js -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /app/containers/sidebar.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | import { connect } from 'react-redux' 3 | import prompt from 'electron-prompt' 4 | import { Item, Menu, Separator, Submenu, theme } from 'react-contexify' 5 | 6 | import { 7 | changeScreen, 8 | hideCabalSettings, 9 | hideProfilePanel, 10 | joinChannel, 11 | saveCabalSettings, 12 | setUsername, 13 | showCabalSettings, 14 | showChannelBrowser, 15 | showProfilePanel, 16 | viewChannel 17 | } from '../actions' 18 | import Avatar from './avatar' 19 | 20 | const mapStateToProps = state => { 21 | const cabal = state.cabals[state.currentCabal] 22 | const addr = cabal.addr 23 | return { 24 | addr, 25 | cabals: state.cabals, 26 | cabal, 27 | cabalSettingsVisible: state.cabalSettingsVisible, 28 | channelMessagesUnread: cabal.channelMessagesUnread, 29 | settings: state.cabalSettings[addr] || {}, 30 | username: cabal.username 31 | } 32 | } 33 | 34 | const mapDispatchToProps = dispatch => ({ 35 | changeScreen: ({ screen }) => dispatch(changeScreen({ screen })), 36 | hideCabalSettings: () => dispatch(hideCabalSettings()), 37 | hideProfilePanel: ({ addr }) => dispatch(hideProfilePanel({ addr })), 38 | joinChannel: ({ addr, channel }) => dispatch(joinChannel({ addr, channel })), 39 | saveCabalSettings: ({ addr, settings }) => dispatch(saveCabalSettings({ addr, settings })), 40 | setUsername: ({ addr, username }) => dispatch(setUsername({ addr, username })), 41 | showCabalSettings: ({ addr }) => dispatch(showCabalSettings({ addr })), 42 | showChannelBrowser: ({ addr }) => dispatch(showChannelBrowser({ addr })), 43 | showProfilePanel: ({ addr, userKey }) => dispatch(showProfilePanel({ addr, userKey })), 44 | viewChannel: ({ addr, channel }) => dispatch(viewChannel({ addr, channel })) 45 | }) 46 | 47 | function UserMenu (props) { 48 | useEffect(() => { 49 | // console.warn('UserMenu', props) 50 | }, [props.peer]) 51 | 52 | return ( 53 | 54 | Add as moderator 55 | Add as administrator 56 | 57 | Block 58 | Hide cabal-wide 59 | Hide in this channel 60 | Mute cabal-wide 61 | Mute in this channel 62 | Hide in this channel 63 | 64 | 😺 65 | 66 | 67 | 🌴 68 | 69 | 70 | ) 71 | } 72 | 73 | class SidebarScreen extends React.Component { 74 | onClickNewChannel () { 75 | prompt({ 76 | title: 'Create a channel', 77 | label: 'New channel name', 78 | value: undefined, 79 | type: 'input' 80 | }).then((newChannelName) => { 81 | if (newChannelName && newChannelName.trim().length > 0) { 82 | this.joinChannel(newChannelName) 83 | } 84 | }).catch(() => { 85 | console.log('cancelled new channel') 86 | }) 87 | } 88 | 89 | onClickUsername () { 90 | prompt({ 91 | title: 'Set nickname', 92 | label: 'What would you like to call yourself?', 93 | value: this.props.cabal.username, 94 | type: 'input' 95 | }).then((username) => { 96 | if (username && username.trim().length > 0) { 97 | this.props.setUsername({ username, addr: this.props.addr }) 98 | } 99 | }).catch(() => { 100 | console.log('cancelled username') 101 | }) 102 | } 103 | 104 | onClickCabalSettings (addr) { 105 | if (this.props.cabalSettingsVisible) { 106 | this.props.hideCabalSettings() 107 | } else { 108 | this.props.showCabalSettings({ addr }) 109 | } 110 | } 111 | 112 | onClickChannelBrowser (addr) { 113 | this.props.showChannelBrowser({ addr }) 114 | } 115 | 116 | onToggleCollection (collection) { 117 | const option = `sidebar-hide-${collection}` 118 | const settings = this.props.settings 119 | settings[option] = !this.props.settings[option] 120 | this.props.saveCabalSettings({ addr: this.props.cabal.addr, settings }) 121 | } 122 | 123 | onClickUser (user) { 124 | this.props.showProfilePanel({ 125 | addr: this.props.addr, 126 | userKey: user.key 127 | }) 128 | } 129 | 130 | onContextMenu (peer, e) { 131 | // e.preventDefault() 132 | // contextMenu.show({ 133 | // id: 'user_menu', 134 | // event: e, 135 | // props: { 136 | // peer 137 | // } 138 | // }) 139 | } 140 | 141 | onClickStartPM (key) { 142 | this.props.hideProfilePanel({ addr: this.props.addr }) 143 | this.props.joinChannel({ 144 | addr: this.props.addr, 145 | channel: key 146 | }) 147 | } 148 | 149 | joinChannel (channel) { 150 | var addr = this.props.addr 151 | this.props.joinChannel({ addr, channel }) 152 | } 153 | 154 | selectChannel (channel) { 155 | var addr = this.props.addr 156 | this.props.viewChannel({ addr, channel }) 157 | } 158 | 159 | sortByProperty (items = [], property = 'name', direction = 1) { 160 | return items.sort((a, b) => { 161 | if (a[property]) { 162 | return (a[property] || '').toLowerCase() < (b[property] || '').toLowerCase() ? -direction : direction 163 | } else { 164 | if (a.toLowerCase && b.toLowerCase) { 165 | return (a || '').toLowerCase() < (b || '').toLowerCase() ? -direction : direction 166 | } 167 | } 168 | }) 169 | } 170 | 171 | sortUsers (users) { 172 | return users.sort((a, b) => { 173 | if (a.isHidden() && !b.isHidden()) return 1 174 | if (b.isHidden() && !a.isHidden()) return -1 175 | if (a.online && !b.online) return -1 176 | if (b.online && !a.online) return 1 177 | if (a.isAdmin() && !b.isAdmin()) return -1 178 | if (b.isAdmin() && !a.isAdmin()) return 1 179 | if (a.isModerator() && !b.isModerator()) return -1 180 | if (b.isModerator() && !a.isModerator()) return 1 181 | if (a.name && !b.name) return -1 182 | if (b.name && !a.name) return 1 183 | if (a.name && b.name) return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1 184 | return a.key < b.key ? -1 : 1 185 | }) 186 | } 187 | 188 | deduplicatedNicks (users) { 189 | const deduplicatedNicks = [] 190 | users && users.forEach((user) => { 191 | const userIndex = deduplicatedNicks.findIndex((u) => u.name === user.name) 192 | const moderated = user.isHidden() || user.isAdmin() || user.isModerator() 193 | if (user.name && !moderated && userIndex > -1) { 194 | deduplicatedNicks[userIndex].users.push(user) 195 | } else { 196 | deduplicatedNicks.push({ 197 | ...user, 198 | users: [user] 199 | }) 200 | } 201 | }) 202 | return deduplicatedNicks 203 | } 204 | 205 | render () { 206 | const { addr, cabal, settings } = this.props 207 | const cabalLabel = settings.alias || addr 208 | const channelsJoined = cabal.channelsJoined?.slice().sort() || [] 209 | const favorites = channelsJoined.filter(channel => (settings['favorite-channels'] || []).includes(channel)) 210 | const channels = channelsJoined.filter(channel => !favorites.includes(channel)) 211 | const users = this.sortUsers(Object.values(cabal.users) || []) 212 | const deduplicatedNicks = this.deduplicatedNicks(users) 213 | const onlineCount = users.filter(i => !!i.online).length 214 | const userkey = cabal.userkey 215 | const username = cabal.username 216 | const unreadNonFavoriteMessageCount = Object.entries((this.props.channelMessagesUnread || {})).reduce((total, value) => { 217 | return (value[1] && channels.includes(value[0])) ? (total + value[1]) : total 218 | }, 0) 219 | 220 | function getPmChannelName (userKey) { 221 | const key = Object.keys(cabal.users).find((key) => key === userKey) 222 | return cabal.users[key]?.name ?? cabal.channel 223 | } 224 | 225 | return ( 226 |
227 |
228 |
229 |
230 |
231 | 232 |
233 |
234 |
235 |

{cabalLabel}

236 |

237 | {username} 238 |

239 |
240 |
241 | 242 |
243 |
244 |
245 | {!!favorites.length && 246 |
247 |
248 |
249 | ▼ 253 | 254 |
258 | Starred 259 |
260 |
261 |
262 | {!this.props.settings['sidebar-hide-favorites'] && this.sortByProperty(favorites).map((channel) => 263 |
264 |
265 |
{channel}
266 | {this.props.channelMessagesUnread && this.props.channelMessagesUnread[channel] > 0 && 267 |
{this.props.channelMessagesUnread[channel]}
} 268 |
269 |
270 | )} 271 |
} 272 | {!!cabal.pmChannels?.length && 273 |
274 |
275 |
276 | ▼ 280 | 281 |
285 | Private Messages 286 |
287 |
288 |
289 | {!this.props.settings['sidebar-hide-pmChannels'] && this.sortByProperty(cabal.pmChannels).map((channel) => 290 |
291 |
292 |
{getPmChannelName(channel)}
293 | {this.props.channelMessagesUnread && this.props.channelMessagesUnread[channel] > 0 && 294 |
{this.props.channelMessagesUnread[channel]}
} 295 |
296 |
297 | )} 298 |
} 299 |
300 |
301 |
302 | ▼ 306 | 307 |
311 | Channels 312 | {this.props.settings['sidebar-hide-channels'] && unreadNonFavoriteMessageCount > 0 && 313 | {unreadNonFavoriteMessageCount}} 314 |
315 |
316 |
321 | 322 |
323 |
324 | {!this.props.settings['sidebar-hide-channels'] && this.sortByProperty(channels).map((channel) => 325 |
326 |
327 |
{channel}
328 | {this.props.channelMessagesUnread && this.props.channelMessagesUnread[channel] > 0 && 329 |
{this.props.channelMessagesUnread[channel]}
} 330 |
331 |
332 | )} 333 |
334 |
335 |
336 |
337 | ▼ 341 | 342 |
346 | Peers - {onlineCount} online 347 |
348 |
349 |
350 |
351 | {!this.props.settings['sidebar-hide-peers'] && deduplicatedNicks.map((peer, index) => { 352 | const keys = peer.users.map((u) => u.key).join(', ') 353 | const isAdmin = peer.users.some((u) => u.isAdmin()) 354 | const isModerator = peer.users.some((u) => u.isModerator()) 355 | const isHidden = peer.users.some((u) => u.isHidden()) 356 | const isSelf = peer.users.some((u) => u.key === userkey) 357 | const name = isHidden ? peer.name.substring(0, 3) + peer.key.substring(0, 6) : peer.name 358 | return ( 359 |
366 |
367 | {!!peer.online && 368 | Online} 369 | {!peer.online && 370 | Offline} 371 |
372 |
373 | 374 | {peer.name ? name : peer.key.substring(0, 6)} 375 | {peer.users.length > 1 && ({peer.users.length})} 376 | 377 | 💬 378 | {!isAdmin && !isModerator && isHidden && HIDDEN} 379 | {!isAdmin && isModerator && MOD} 380 | {isSelf 381 | ? YOU 382 | : isAdmin && ADMIN} 383 |
384 |
385 |
386 | ) 387 | })} 388 | 389 |
390 |
391 |
392 |
393 | ) 394 | } 395 | } 396 | 397 | const Sidebar = connect(mapStateToProps, mapDispatchToProps)(SidebarScreen) 398 | 399 | export default Sidebar 400 | -------------------------------------------------------------------------------- /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 |