├── .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 |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 |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 |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?
81 |
77 | 78 | Create a Cabal 79 | 80 |21 |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 |22 | 23 |25 | 26 |Cabal Desktop Settings
24 |27 | Nothing to set at the moment. 🤷♀️ 28 |29 | 30 |31 | Version {version} 32 |33 |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 |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 |49 |110 |50 |61 |51 |60 |52 |59 |53 |58 |54 | Settings 55 |
57 |56 |
62 |109 |63 |108 |64 |78 |65 |68 |Invite People66 |Share this key with others to let them join the cabal.67 |69 | 70 | 76 |77 |79 |87 |80 |83 |Cabal Name81 |Set a local name for this cabal. Only you can see this.82 |84 | this.props.saveCabalSettings({ addr: this.props.cabal.addr, settings: { ...this.props.settings, alias: e.target.value } })} /> 85 |86 |88 |98 |89 |97 |90 | { }} /> 91 |92 |93 |96 |Enable desktop notifications94 |Display a notification for new messages for this cabal when a channel is in the background.95 |99 |107 |100 |103 |Remove this cabal from this Cabal Desktop client101 |The local cabal database will remain and may also exist on peer clients.102 |104 | 105 |106 |48 |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 |49 | {cabalKeys.map(function (key) { 50 | var cabal = cabals[key] 51 | if (cabal) { 52 | return ( 53 |66 | {/*54 | 55 | {(settings[key]?.alias || key).slice(0, 2)} 56 | 57 | {cabal.allChannelsUnreadCount > 0 && } 58 |59 | ) 60 | } 61 | })} 62 |63 |65 |64 |
67 |*/} 71 |68 |70 |69 |
74 |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 |75 |157 |76 |89 |77 |88 |78 |84 |79 |83 |80 | Browse Channels 81 |
82 |85 |87 |Create A New Channel86 |90 |156 |91 |155 |Channels you can join
92 |93 | {channelsNotJoined.map((channel) => { 94 | return ( 95 |108 |101 |105 | ) 106 | })} 107 |{channel.name}102 |{channel.topic}103 |{channel.memberCount} {channel.memberCount === 1 ? 'person' : 'people'}104 |Channels you belong to
109 |110 | {channelsJoined.map((channel) => { 111 | return ( 112 |125 | {!!channelsArchived.length && ( 126 | <> 127 |118 |122 | ) 123 | })} 124 |{channel.name}119 |{channel.topic}120 |{channel.memberCount} {channel.memberCount === 1 ? 'person' : 'people'}121 |Archived Channels
128 |129 | {channelsArchived.map((channel) => { 130 | return ( 131 |152 | > 153 | )} 154 |136 |149 | ) 150 | })} 151 |137 |141 | 148 |{channel.name}138 |{channel.topic}139 |{channel.memberCount} {channel.memberCount === 1 ? 'person' : 'people'}140 |42 |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 |43 | Channel Details 44 | hideChannelPanel({ addr })} className='close'>46 | {canLeave && 47 |45 |
48 |} 57 | {hasMembers && 58 | <> 59 |49 | 52 | 55 |56 |60 | Channel Members 61 |62 |63 |65 | >} 66 |64 | 13 |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 |14 |37 |Leave Cabal
15 |16 | Are you sure you want to leave this cabal? 17 |
20 |
18 | This can’t be undone. 19 |21 | 27 | 30 |
31 | 36 |28 |32 | ) 33 | } 34 | 35 | return ( 36 |29 |30 |
Loading hypercores and swarming...31 |37 |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 | return38 | 39 | 40 | {props.channelPanelVisible && } 41 | {props.profilePanelVisible && } 42 | 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 |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 |248 |319 |249 |305 |250 |304 |251 |292 | {!cabal.isChannelPrivate && ( 293 |252 |291 |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 |
290 |272 |280 | 285 | {cabal.topic || 'Add a topic'} 286 | 287 | > 288 | )} 289 |273 |
277 | {channelMemberCount} 278 |279 |294 |302 | )} 303 |299 |301 |300 |
{ 308 | this.removeEventListeners() 309 | this.refScrollContainer = el 310 | }} 311 | > 312 |314 |313 | 318 | onClickUser(user)} title={user.key}> 26 |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 |27 | {!!user.online && 28 |32 |} 29 | {!user.online && 30 |
} 31 |
33 | {!!user.online && 34 | {user.name || user.key.substring(0, 6)}} 35 | {!user.online && 36 |{user.name || user.key.substring(0, 6)}} 37 | 38 |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 |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 |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 |90 |94 |91 |93 |92 | 95 |98 |{message.name || defaultSystemName}{renderDate(formattedTime)}96 |{enriched.content}97 |109 |130 | ) 131 | } 132 | if (message.type === 'chat/text') { 133 | item = ( 134 |110 |114 |111 |113 |112 | 115 |129 |{message.name || defaultSystemName}{renderDate(formattedTime)}116 |117 | {role === 'hide' && 118 |128 |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 |135 |151 | ) 152 | } 153 | if (message.type === 'chat/emote') { 154 | item = ( 155 |136 | {repeatedAuthor ? null :138 |} 137 | 139 | {!repeatedAuthor && 140 |150 |141 | {user.name} 142 | {user.isAdmin() && @} 143 | {!user.isAdmin() && user.isModerator() && %} 144 | {renderDate(formattedTime)} 145 |} 146 |147 | {enriched.content} 148 |149 |156 |166 | ) 167 | } 168 | return ( 169 |157 |161 |158 | {repeatedAuthor ? null :160 |} 159 | 162 | {repeatedAuthor ? null :165 |{user.name}{renderDate(formattedTime)}} 163 |{enriched.content}164 |170 | {showDivider && ( 171 |177 | ) 178 | })} 179 |172 |174 | )} 175 | {item} 176 |{formattedTime.long} ({printDate.fromNow()})
173 |89 |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 | 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 |90 | Profile 91 | props.hideProfilePanel({ addr: props.addr })} className='close'>93 |92 |
94 |113 |95 |112 |96 | 97 | {!!user.online && 98 | } 99 | {!user.online && 100 | } 101 |102 |{user.name}
103 |{user.key}
104 |105 | {isSelf 106 | ?111 |You107 | : user.isAdmin() &&Admin} 108 | {user.isModerator() &&Moderator} 109 | {user.isHidden() &&Hidden} 110 |114 | Messages 115 |116 |117 |122 | {!isSelf && 123 | <> 124 |118 | 119 |121 |Start an encrypted 1-on-1 chat that only you and this peer can read.120 |125 | Moderation 126 |127 |128 |161 | >} 162 |129 | {!user.isHidden() && 130 | <> 131 | 132 |160 |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 |227 |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 |228 |392 |229 |244 |230 |234 |231 |233 |232 | 235 |240 |{cabalLabel}
236 |237 | {username} 238 |
239 |241 |243 |242 |
245 | {!!favorites.length && 246 |391 |247 |} 272 | {!!cabal.pmChannels?.length && 273 |248 |262 | {!this.props.settings['sidebar-hide-favorites'] && this.sortByProperty(favorites).map((channel) => 263 |249 | ▼ 253 | 254 |261 |258 | Starred 259 |260 |264 |270 | )} 271 |265 |{channel}266 | {this.props.channelMessagesUnread && this.props.channelMessagesUnread[channel] > 0 && 267 |{this.props.channelMessagesUnread[channel]}} 268 | 269 |274 |} 299 |275 |289 | {!this.props.settings['sidebar-hide-pmChannels'] && this.sortByProperty(cabal.pmChannels).map((channel) => 290 |276 | ▼ 280 | 281 |288 |285 | Private Messages 286 |287 |291 |297 | )} 298 |292 |{getPmChannelName(channel)}293 | {this.props.channelMessagesUnread && this.props.channelMessagesUnread[channel] > 0 && 294 |{this.props.channelMessagesUnread[channel]}} 295 | 296 |300 |334 |301 |324 | {!this.props.settings['sidebar-hide-channels'] && this.sortByProperty(channels).map((channel) => 325 |302 | ▼ 306 | 307 |316 |311 | Channels 312 | {this.props.settings['sidebar-hide-channels'] && unreadNonFavoriteMessageCount > 0 && 313 | {unreadNonFavoriteMessageCount}} 314 |315 |321 |323 |322 |
326 |332 | )} 333 |327 |{channel}328 | {this.props.channelMessagesUnread && this.props.channelMessagesUnread[channel] > 0 && 329 |{this.props.channelMessagesUnread[channel]}} 330 | 331 |335 |390 |336 |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 |337 | ▼ 341 | 342 |349 | 350 |346 | Peers - {onlineCount} online 347 |348 |366 |386 | ) 387 | })} 388 |367 | {!!peer.online && 368 |372 |} 369 | {!peer.online && 370 |
} 371 |
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 |389 | 285 | {showScrollToBottom && ( 286 |325 | ) 326 | } 327 | } 328 | 329 | const WriteContainer = connect(mapStateToProps, mapDispatchToProps)(writeScreen) 330 | 331 | export default WriteContainer 332 | -------------------------------------------------------------------------------- /app/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render } from 'react-dom' 3 | import { ipcRenderer } from 'electron' 4 | import { Provider } from 'react-redux' 5 | import { createStore, applyMiddleware, compose } from 'redux' 6 | import reducer from './reducer' 7 | import App from './app' 8 | import logger from 'redux-logger' 9 | import thunk from 'redux-thunk' 10 | 11 | // Disable debug console.log messages coming from dependencies 12 | window.localStorage.removeItem('debug') 13 | 14 | const middlewares = [thunk] 15 | 16 | if (process.env.ENABLE_APP_LOG) { 17 | middlewares.push(logger) 18 | } 19 | 20 | const store = createStore( 21 | reducer, 22 | compose(applyMiddleware(...middlewares)) 23 | ) 24 | 25 | ipcRenderer.on('darkMode', (event, darkMode) => { 26 | store.dispatch({ 27 | type: 'CHANGE_DARK_MODE', 28 | darkMode 29 | }) 30 | }) 31 | 32 | render( 33 |287 | Newer messages below. Jump to latest ↓ 288 |)} 289 |290 | {/*324 |*/} 291 |this.focusInput()}> 292 | 306 |307 |{ this.emojiPicker = el }} 310 | style={{ position: 'absolute', bottom: '100px', right: '16px', display: this.props.emojiPickerVisible ? 'block' : 'none' }} 311 | onClick={this.onClickEmojiPickerContainer.bind(this)} 312 | > 313 |322 |this.addEmoji(e)} 315 | native 316 | sheetSize={64} 317 | autoFocus 318 | emoji='point_up' 319 | title='Pick an emoji...' 320 | /> 321 | this.toggleEmojiPicker()}>323 |34 | , 36 | document.querySelector('#root') 37 | ) 38 | -------------------------------------------------------------------------------- /app/platform.js: -------------------------------------------------------------------------------- 1 | function isMac () { 2 | if (typeof window === 'undefined'){ 3 | const process = require('process') 4 | return process.platform === 'darwin' 5 | } else { 6 | return window.navigator.platform.toLowerCase().indexOf('mac') >= 0 7 | } 8 | } 9 | 10 | var platform = { 11 | mac: isMac() 12 | } 13 | 14 | module.exports = platform -------------------------------------------------------------------------------- /app/reducer.js: -------------------------------------------------------------------------------- 1 | import { createReducer } from '@reduxjs/toolkit' 2 | import { setAutoFreeze } from 'immer' 3 | import settings from './settings' 4 | 5 | // set auto freeze to false to prevent freezing the object. 6 | // currently data is shared between cabal-client lib and redux. 7 | // so if frozen, it will cause issues since lib is mutating some code.- 8 | setAutoFreeze(false) 9 | 10 | let darkMode = settings.get('darkMode') 11 | // if its not explicitly set, set it in darkMode 12 | if (typeof darkMode === 'undefined') darkMode = true 13 | 14 | const defaultState = { 15 | cabals: {}, 16 | cabalSettings: {}, 17 | cabalSettingsVisible: false, 18 | channelMembers: [], 19 | channelPanelVisible: {}, 20 | currentCabal: null, 21 | currentChannel: 'default', 22 | emojiPickerVisible: false, 23 | profilePanelUser: {}, 24 | profilePanelVisible: {}, 25 | screen: 'main', 26 | screenViewHistory: [], 27 | screenViewHistoryPosition: 0, 28 | globalSettings: { 29 | darkMode 30 | } 31 | } 32 | 33 | const reducer = createReducer(defaultState, { 34 | CHANGE_DARK_MODE: (state, { darkMode }) => { 35 | state.globalSettings.darkMode = darkMode 36 | }, 37 | CHANGE_SCREEN: (state, { screen, addr }) => { 38 | state.screen = screen 39 | state.addr = addr 40 | }, 41 | VIEW_CABAL: (state, { channel, addr }) => { 42 | state.currentCabal = addr 43 | state.currentChannel = channel || state.currentChannel 44 | }, 45 | ADD_CABAL: (state, action) => { 46 | state.cabals[action.addr] = { 47 | ...state.cabals[action.addr], 48 | messages: [], 49 | ...action 50 | } 51 | }, 52 | UPDATE_CABAL: (state, action = {}) => { 53 | state.cabals[action.addr] = { 54 | ...state.cabals[action.addr], 55 | ...action 56 | } 57 | }, 58 | UPDATE_CABAL_SETTINGS: (state, { addr, settings }) => { 59 | state.cabalSettings[addr] = { 60 | ...state.cabalSettings[addr], 61 | ...settings 62 | } 63 | }, 64 | UPDATE_TOPIC: (state, { addr, topic }) => { 65 | state.cabals[addr].topic = topic 66 | }, 67 | DELETE_CABAL: (state, { addr }) => { 68 | delete state.cabals[addr] 69 | }, 70 | SHOW_CHANNEL_BROWSER: (state) => { 71 | state.channelBrowserVisible = true 72 | // state.profilePanelVisible[addr] = false 73 | }, 74 | UPDATE_CHANNEL_BROWSER: (state, { channelsData }) => { 75 | state.channelBrowserChannelsData = channelsData 76 | }, 77 | SHOW_CABAL_SETTINGS: (state) => { 78 | state.cabalSettingsVisible = true 79 | state.emojiPickerVisible = false 80 | // state.profilePanelVisible[addr] = false 81 | }, 82 | HIDE_CABAL_SETTINGS: state => { state.cabalSettingsVisible = false }, 83 | HIDE_ALL_MODALS: state => { 84 | state.cabalSettingsVisible = false 85 | state.emojiPickerVisible = false 86 | state.channelBrowserVisible = false 87 | }, 88 | UPDATE_WINDOW_BADGE: (state, { badgeCount }) => { state.badgeCount = badgeCount }, 89 | SHOW_EMOJI_PICKER: (state) => { state.emojiPickerVisible = true }, 90 | HIDE_EMOJI_PICKER: state => { state.emojiPickerVisible = false }, 91 | SHOW_PROFILE_PANEL: (state, { addr, userKey }) => { 92 | state.profilePanelVisible[addr] = true 93 | state.profilePanelUser[addr] = userKey 94 | }, 95 | HIDE_PROFILE_PANEL: (state, { addr }) => { 96 | state.profilePanelVisible[addr] = false 97 | }, 98 | SHOW_CHANNEL_PANEL: (state, { addr }) => { 99 | state.channelPanelVisible[addr] = true 100 | }, 101 | HIDE_CHANNEL_PANEL: (state, { addr }) => { 102 | state.channelPanelVisible[addr] = false 103 | }, 104 | UPDATE_SCREEN_VIEW_HISTORY: (state, { addr, channel }) => { 105 | state.screenViewHistory.push({ addr, channel }) 106 | state.screenViewHistoryPosition = state.screenViewHistory.length - 1 107 | }, 108 | SET_SCREEN_VIEW_HISTORY_POSITION: (state, { index }) => { 109 | state.screenViewHistoryPosition = index 110 | } 111 | }) 112 | 113 | export default reducer 114 | -------------------------------------------------------------------------------- /app/selectors.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from '@reduxjs/toolkit' 2 | import { prop } from 'lodash/fp' 3 | export const currentCabalSelector = createSelector( 4 | state => state.currentCabal, 5 | state => state.cabals || {}, 6 | (currentCabal, cabals) => cabals[currentCabal] || {} 7 | ) 8 | 9 | function sortUsers(users = []) { 10 | if (Array.isArray(users)) { 11 | return users.sort((a, b) => { 12 | if (a.isHidden() && !b.isHidden()) return 1 13 | if (b.isHidden() && !a.isHidden()) return -1 14 | if (a.online && !b.online) return -1 15 | if (b.online && !a.online) return 1 16 | if (a.isAdmin() && !b.isAdmin()) return -1 17 | if (b.isAdmin() && !a.isAdmin()) return 1 18 | if (a.isModerator() && !b.isModerator()) return -1 19 | if (b.isModerator() && !a.isModerator()) return 1 20 | if (a.name && !b.name) return -1 21 | if (b.name && !a.name) return 1 22 | if (a.name && b.name) return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1 23 | return a.key < b.key ? -1 : 1 24 | }) 25 | } 26 | } 27 | 28 | export const currentChannelMembersSelector = createSelector( 29 | currentCabalSelector, 30 | cabal => sortUsers(cabal.channelMembers) 31 | ) 32 | 33 | export const currentChannelMemberCountSelector = createSelector( 34 | currentChannelMembersSelector, 35 | (members = []) => members.length 36 | ) 37 | 38 | // check if all cabals are initialized and current one is set 39 | export const isCabalsInitializedSelector = createSelector( 40 | state => state.currentCabal, 41 | state => state.cabals || {}, 42 | (current, cabals) => { 43 | const cabalsInitialized = Object.values(cabals).every(prop('initialized')) 44 | return cabalsInitialized && !!current 45 | } 46 | ) 47 | 48 | 49 | // current cabals settings 50 | export const cabalSettingsSelector = createSelector( 51 | state => state?.currentCabal || "", 52 | state => state.cabalSettings, 53 | (addr, settings) => settings[addr] || {} 54 | ) 55 | 56 | // messages of current cabal 57 | export const currentChannelMessagesSelector = createSelector( 58 | currentCabalSelector, 59 | cabal => cabal?.messages || [] 60 | ) 61 | 62 | 63 | // select current channel 64 | export const currentChannelSelector = createSelector( 65 | currentCabalSelector, 66 | cabal => cabal?.channel 67 | ) 68 | -------------------------------------------------------------------------------- /app/settings.js: -------------------------------------------------------------------------------- 1 | const Store = require('electron-store') 2 | 3 | const store = new Store({ name: 'cabal-desktop-settings' }) 4 | const store_defaults = { 5 | 'auto-update': true 6 | } 7 | 8 | for (var key in store_defaults) { 9 | if (store.get(key) === undefined) store.set(key, store_defaults[key]) 10 | } 11 | 12 | module.exports = store 13 | -------------------------------------------------------------------------------- /app/styles/darkmode.scss: -------------------------------------------------------------------------------- 1 | /* 2 | ------------------------------------------------------ 3 | DARKMODE 4 | ------------------------------------------------------ 5 | 6 | This is a temporary hack so we can enjoy darkmode in the short term until we finish 7 | a better approach for customizing styles in `cabal-ui` :D 8 | */ 9 | 10 | $backgroundColor: #16161d; 11 | $backgroundColor2: #222; 12 | $backgroundColor3: #333; 13 | $borderColor: #444; 14 | $borderColor2: #888; 15 | $borderColor3: #ddd; 16 | $borderColor4: #fff; 17 | $buttonTextColor: #ccc; 18 | $buttonBackgroundColor: #1f0f50; 19 | $highlightColor: #9571D6; 20 | $linkColor: #4393e6; 21 | $textColor: #fff; 22 | $textColor2: #aaa; 23 | 24 | 25 | .client.darkmode { 26 | color: $textColor2; 27 | 28 | a { 29 | color: $linkColor; 30 | } 31 | 32 | .button { 33 | background-color: $buttonBackgroundColor; 34 | border-color: $highlightColor; 35 | color: $highlightColor; 36 | } 37 | .button:hover { 38 | border-color: $highlightColor; 39 | color: $buttonTextColor; 40 | } 41 | 42 | // .client__cabals { 43 | // background-color: $backgroundColor; 44 | // border-right: 1px solid $borderColor; 45 | // color: $textColor; 46 | // } 47 | 48 | // .client__cabals .switcher__item { 49 | // border: 2px solid $borderColor3; 50 | // color: $textColor; 51 | // } 52 | 53 | // .client__cabals .switcher__item:hover { 54 | // border: 2px solid rgba(255, 255, 255, 0.5); 55 | // color: $textColor2; 56 | // } 57 | 58 | // .client__cabals .switcher__item--active { 59 | // background-color: $highlightColor; 60 | // border: 2px solid $highlightColor; 61 | // color: $textColor !important; 62 | // } 63 | 64 | // .client__cabals .unreadIndicator { 65 | // background: $highlightColor; 66 | // } 67 | 68 | // .client__cabals .client__cabals__footer { 69 | // .settingsButton { 70 | // &:hover { 71 | // color: $textColor; 72 | // } 73 | // } 74 | // } 75 | 76 | .client__sidebar { 77 | background-color: $backgroundColor; 78 | color: $textColor; 79 | 80 | .session .session__meta h2 { 81 | color: $textColor2; 82 | } 83 | 84 | .session .session__configuration { 85 | // background-color: rgba(255, 255, 255, 0); 86 | } 87 | 88 | .collection { 89 | border-top-color: $borderColor; 90 | } 91 | 92 | .collection .collection__heading .collection__heading__title { 93 | color: $textColor2; 94 | } 95 | 96 | .collection .collection__item:hover { 97 | background-color: $backgroundColor2; 98 | } 99 | 100 | .collection .collection__item .collection__item__content { 101 | color: $textColor2; 102 | } 103 | 104 | .collection .collection__item .collection__item__messagesUnreadCount { 105 | color: $textColor; 106 | background-color: $highlightColor; 107 | } 108 | 109 | .collection .collection__item.active .collection__item__content, 110 | .collection .collection__item .collection__item__content.active { 111 | color: $textColor; 112 | } 113 | } 114 | 115 | .client__main { 116 | background-color: $backgroundColor; 117 | 118 | .window { 119 | border-left: 1px solid $borderColor; 120 | } 121 | 122 | .window__header { 123 | background-color: $backgroundColor; 124 | 125 | &.private { 126 | background-color: #693afa50; 127 | } 128 | } 129 | 130 | .channel-meta { 131 | border-bottom: 1px solid $borderColor; 132 | 133 | .channel-meta__data__details h1 { 134 | color: $textColor; 135 | } 136 | 137 | .channel-meta__data__details h2 { 138 | color: $textColor2; 139 | } 140 | 141 | .channel-meta__other .channel-meta__other__more { 142 | filter: invert(100%); 143 | } 144 | } 145 | .messages__item { 146 | &:hover { 147 | background-color: $backgroundColor2; 148 | } 149 | } 150 | 151 | .messages__item--system { 152 | background: $backgroundColor; 153 | } 154 | 155 | .messages__item__metadata { 156 | .messages__item__metadata__name { 157 | color: $textColor; 158 | } 159 | 160 | span { 161 | color: $textColor2; 162 | } 163 | 164 | div.text { 165 | pre { 166 | background-color: $backgroundColor3; 167 | } 168 | 169 | p code { 170 | background-color: $backgroundColor3; 171 | } 172 | 173 | blockquote { 174 | background: $backgroundColor3; 175 | } 176 | } 177 | 178 | a.link { 179 | color: $linkColor; 180 | 181 | &:hover { 182 | color: $linkColor; 183 | } 184 | } 185 | 186 | .cabal-settings__close { 187 | filter: invert(100%); 188 | } 189 | } 190 | 191 | .cabal-settings__item { 192 | border-bottom: 1px solid $borderColor; 193 | 194 | .cabal-settings__item-input { 195 | input { 196 | color: $textColor; 197 | background-color: $backgroundColor3; 198 | 199 | &.cabalKey { 200 | 201 | } 202 | } 203 | } 204 | } 205 | 206 | .cabal-settings__item-label-description { 207 | color: $textColor2; 208 | } 209 | 210 | .composer { 211 | background-color: $backgroundColor3; 212 | border: 2px solid $borderColor; 213 | 214 | .composer__input textarea { 215 | color: $textColor; 216 | background-color: $backgroundColor3; 217 | } 218 | 219 | &:hover, &:active { 220 | border-color: $borderColor2; 221 | } 222 | 223 | .composer__meta { 224 | border-right: 2px solid $borderColor; 225 | } 226 | 227 | .composer__other { 228 | filter: invert(100%) 229 | } 230 | } 231 | } 232 | 233 | .modalScreen { 234 | background: $backgroundColor; 235 | 236 | .modalScreen__header { 237 | .modalScreen__close { 238 | background-color: $backgroundColor; 239 | color: $textColor2; 240 | 241 | &:hover { 242 | color: $textColor; 243 | } 244 | } 245 | } 246 | } 247 | 248 | .modal-overlay { 249 | background-color: $backgroundColor; 250 | } 251 | 252 | .modal-overlay .modal { 253 | background-color: $backgroundColor; 254 | color: $textColor; 255 | } 256 | 257 | .modal-overlay .modal li a, 258 | .modal-overlay .modal li button { 259 | color: $textColor2; 260 | background: none; 261 | } 262 | 263 | .modal-overlay .modal li a:hover, 264 | .modal-overlay .modal li button:hover { 265 | background-color: $backgroundColor3; 266 | } 267 | 268 | ::-webkit-scrollbar-thumb { 269 | background-color: $backgroundColor3; 270 | } 271 | 272 | .sidebar ::-webkit-scrollbar-thumb { 273 | background-color: $backgroundColor3; 274 | } 275 | 276 | .messages__date__divider { 277 | h2 { 278 | color: $borderColor; 279 | } 280 | 281 | h2:before, 282 | h2:after { 283 | background-color: $borderColor; 284 | } 285 | } 286 | 287 | .channelBrowser__sectionTitle { 288 | border-bottom: 1px solid $borderColor; 289 | } 290 | 291 | .channelBrowser__row { 292 | border-bottom: 1px solid $borderColor; 293 | 294 | &:hover { 295 | background-color: $backgroundColor2; 296 | } 297 | 298 | .topic { 299 | color: $highlightColor; 300 | } 301 | 302 | .members { 303 | color: $textColor2; 304 | } 305 | } 306 | 307 | 308 | .panel { 309 | background-color: $backgroundColor; 310 | border-left-color: $borderColor; 311 | 312 | .panel__header { 313 | border-bottom-color: $borderColor; 314 | 315 | .close { 316 | filter: invert(100%); 317 | } 318 | } 319 | 320 | .panel__content { 321 | .collection__item { 322 | &:hover { 323 | background: $backgroundColor3; 324 | } 325 | } 326 | } 327 | 328 | .section__header { 329 | border-top-color: $borderColor; 330 | } 331 | } 332 | 333 | .profilePanel { 334 | .panel__header { 335 | background: $backgroundColor; 336 | } 337 | 338 | .avatar__online__indicator { 339 | border-color: $backgroundColor; 340 | } 341 | 342 | .help__text { 343 | color: $textColor2; 344 | } 345 | } 346 | } 347 | -------------------------------------------------------------------------------- /app/styles/react-contexify.css: -------------------------------------------------------------------------------- 1 | /* https://github.com/fkhadra/react-contexify/blob/master/dist/ReactContexify.min.css */ 2 | .react-contexify{position:fixed;opacity:0;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;background-color:#fff;box-sizing:border-box;box-shadow:0 10px 20px rgba(0,0,0,.3),0 0 0 1px #eee;padding:5px 0;min-width:180px}.react-contexify .react-contexify__submenu{position:absolute;top:0;pointer-events:none;transition:opacity .275s}.react-contexify__submenu-arrow{font-size:12px;position:absolute;right:10px;line-height:22px}.react-contexify__separator{float:left;width:100%;height:1px;cursor:default;margin:4px 0;background-color:rgba(0,0,0,.2)}.react-contexify__item{cursor:pointer;position:relative}.react-contexify__item:not(.react-contexify__item--disabled):hover>.react-contexify__item__content{color:#fff;background-color:#4393e6}.react-contexify__item:not(.react-contexify__item--disabled):hover>.react-contexify__submenu{pointer-events:auto;opacity:1}.react-contexify__item--disabled{cursor:default;opacity:.5}.react-contexify__item__content{padding:6px 12px;display:-ms-flexbox;display:flex;text-align:left;white-space:nowrap;color:#333;position:relative}.react-contexify__item__icon{font-size:20px;margin-right:5px;font-style:normal}.react-contexify__theme--dark{padding:6px 0;box-shadow:0 2px 15px rgba(0,0,0,.4),0 0 0 1px #222}.react-contexify__theme--dark,.react-contexify__theme--dark .react-contexify__submenu{background-color:rgba(40,40,40,.98)}.react-contexify__theme--dark .react-contexify__separator{background-color:#eee}.react-contexify__theme--dark .react-contexify__item__content{color:#fff}.react-contexify__theme--dark .react-contexify__item__icon{margin-right:8px;width:12px;text-align:center}.react-contexify__theme--light{padding:6px 0;box-shadow:0 2px 15px rgba(0,0,0,.2),0 0 0 1px #eee}.react-contexify__theme--light .react-contexify__separator{background-color:#eee}.react-contexify__theme--light .react-contexify__item:not(.react-contexify__item--disabled):hover>.react-contexify__item__content{color:#4393e6;background-color:#e0eefd}.react-contexify__theme--light .react-contexify__item__content{color:#666}.react-contexify__theme--light .react-contexify__item__icon{margin-right:8px;width:12px;text-align:center}@keyframes react-contexify__popIn{0%{transform:scale(0)}to{transform:scale(1)}}@keyframes react-contexify__popOut{0%{transform:scale(1)}to{transform:scale(0)}}.react-contexify__will-enter--pop{animation:react-contexify__popIn .3s cubic-bezier(.51,.92,.24,1.2)}.react-contexify__will-leave--pop{animation:react-contexify__popOut .3s cubic-bezier(.51,.92,.24,1.2)}@keyframes react-contexify__zoomIn{0%{opacity:0;transform:scale3d(.3,.3,.3)}50%{opacity:1}}@keyframes react-contexify__zoomOut{0%{opacity:1}50%{opacity:0;transform:scale3d(.3,.3,.3)}to{opacity:0}}.react-contexify__will-enter--zoom{transform-origin:top left;animation:react-contexify__zoomIn .4s}.react-contexify__will-leave--zoom{animation:react-contexify__zoomOut .4s}@keyframes react-contexify__fadeIn{0%{opacity:0}to{opacity:1}}@keyframes react-contexify__fadeOut{0%{opacity:1}to{opacity:0}}.react-contexify__will-enter--fade{animation:react-contexify__fadeIn .3s ease}.react-contexify__will-leave--fade{animation:react-contexify__fadeOut .3s ease}@keyframes react-contexify__flipInX{0%{transform:perspective(400px) rotateX(90deg);animation-timing-function:ease-in}40%{transform:perspective(400px) rotateX(-20deg);animation-timing-function:ease-in}60%{transform:perspective(400px) rotateX(10deg)}80%{transform:perspective(400px) rotateX(-5deg)}to{transform:perspective(400px)}}@keyframes react-contexify__flipOutX{0%{transform:perspective(400px)}30%{transform:perspective(400px) rotateX(-20deg);opacity:1}to{transform:perspective(400px) rotateX(90deg);opacity:0}}.react-contexify__will-enter--flip{animation:react-contexify__flipInX .65s}.react-contexify__will-enter--flip,.react-contexify__will-leave--flip{-webkit-backface-visibility:visible!important;backface-visibility:visible!important}.react-contexify__will-leave--flip{animation:react-contexify__flipOutX .65s} 3 | -------------------------------------------------------------------------------- /app/updater.js: -------------------------------------------------------------------------------- 1 | const { autoUpdater } = require('electron-updater') 2 | 3 | class AutoUpdater { 4 | constructor () { 5 | this.interval = false 6 | } 7 | 8 | start () { 9 | const FOUR_HOURS = 60 * 60 * 1000 10 | try { 11 | this.interval = setInterval(() => autoUpdater.checkForUpdatesAndNotify(), FOUR_HOURS) 12 | autoUpdater.checkForUpdatesAndNotify() 13 | } catch (err) { 14 | // If offline, the auto updater will throw an error. 15 | console.error(err) 16 | } 17 | } 18 | 19 | stop () { 20 | clearInterval(this.interval) 21 | } 22 | } 23 | 24 | module.exports = AutoUpdater 25 | -------------------------------------------------------------------------------- /bin/build-multi: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | docker run --rm -ti \ 4 | --env-file <(env | grep -iE 'DEBUG|NODE_|ELECTRON_|YARN_|NPM_|CI|CIRCLE|TRAVIS_TAG|TRAVIS|TRAVIS_REPO_|TRAVIS_BUILD_|TRAVIS_BRANCH|TRAVIS_PULL_REQUEST_|APPVEYOR_|CSC_|GH_|GITHUB_|BT_|AWS_|STRIP|BUILD_') \ 5 | --env ELECTRON_CACHE="/root/.cache/electron" \ 6 | --env ELECTRON_BUILDER_CACHE="/root/.cache/electron-builder" \ 7 | -v ${PWD}:/project \ 8 | -v ${PWD##*/}-node-modules:/project/node_modules \ 9 | -v ~/.cache/electron:/root/.cache/electron \ 10 | -v ~/.cache/electron-builder:/root/.cache/electron-builder \ 11 | electronuserland/builder:wine \ 12 | /bin/bash -c "uname -a && yarn install && yarn run build && yarn run dist:multi" 13 | -------------------------------------------------------------------------------- /build/entitlements.mac.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 |35 | 4 | 15 | -------------------------------------------------------------------------------- /build/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cabal-club/cabal-desktop/e19cd37cac3b8f90447c70839d6cb5792628a503/build/icon.icns -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 42 | 43 | 44 |5 | 14 |com.apple.security.cs.allow-jit 6 |7 | com.apple.security.cs.allow-unsigned-executable-memory 8 |9 | com.apple.security.cs.debugger 10 |11 | com.apple.security.cs.disable-library-validation 12 |13 | 45 |51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { app, BrowserWindow, shell, Menu, ipcMain } = require('electron') 4 | const isDev = require('electron-is-dev') 5 | const windowStateKeeper = require('electron-window-state') 6 | const os = require('os') 7 | const path = require('path') 8 | const settings = require('./app/settings') 9 | const AutoUpdater = require('./app/updater') 10 | const platform = require('./app/platform') 11 | 12 | const updater = new AutoUpdater() 13 | 14 | // the window object 15 | let win 16 | 17 | if (isDev) { 18 | require('electron-reload')(__dirname, { 19 | electron: path.join(__dirname, 'node_modules', '.bin', 'electron'), 20 | hardResetMethod: 'exit' 21 | }) 22 | } 23 | 24 | const template = [ 25 | { 26 | label: 'Edit', 27 | submenu: [ 28 | { role: 'undo' }, 29 | { role: 'redo' }, 30 | { type: 'separator' }, 31 | { role: 'cut' }, 32 | { role: 'copy' }, 33 | { role: 'paste' }, 34 | { role: 'pasteandmatchstyle' }, 35 | { role: 'delete' }, 36 | { role: 'selectall' } 37 | ] 38 | }, 39 | { 40 | label: 'View', 41 | submenu: [ 42 | { role: 'reload' }, 43 | { role: 'forcereload' }, 44 | { role: 'toggledevtools' }, 45 | { type: 'separator' }, 46 | { role: 'resetzoom' }, 47 | { role: 'zoomin' }, 48 | { role: 'zoomout' }, 49 | { type: 'separator' }, 50 | { role: 'togglefullscreen' }, 51 | { 52 | label: 'Night Mode', 53 | type: 'checkbox', 54 | checked: settings.get('darkMode'), 55 | click (menuItem) { settings.set('darkMode', menuItem.checked); win.webContents.send('darkMode', menuItem.checked) } 56 | } 57 | ] 58 | }, 59 | { 60 | role: 'window', 61 | submenu: [ 62 | { role: 'minimize' }, 63 | { role: 'close' } 64 | ] 65 | }, 66 | { 67 | role: 'help', 68 | submenu: [ 69 | { 70 | label: 'Learn More', 71 | click () { require('electron').shell.openExternal('https://cabal.chat/') } 72 | }, 73 | { 74 | label: 'Report Issue', 75 | click () { require('electron').shell.openExternal('https://github.com/cabal-club/cabal-desktop/issues/new') } 76 | }, 77 | { 78 | label: 'Automatically Check for Updates', 79 | type: 'checkbox', 80 | checked: settings.get('auto-update'), 81 | click (menuItem) { 82 | settings.set('auto-update', menuItem.checked) 83 | menuItem.checked ? updater.start() : updater.stop() 84 | } 85 | } 86 | ] 87 | } 88 | ] 89 | 90 | if (platform.mac) { 91 | template.unshift({ 92 | label: 'Cabal', 93 | submenu: [ 94 | { role: 'about' }, 95 | { type: 'separator' }, 96 | { role: 'services', submenu: [] }, 97 | { type: 'separator' }, 98 | { role: 'hide' }, 99 | { role: 'hideothers' }, 100 | { role: 'unhide' }, 101 | { type: 'separator' }, 102 | { role: 'quit' } 103 | ] 104 | }) 105 | 106 | // Edit menu 107 | template[1].submenu.push( 108 | { type: 'separator' }, 109 | { 110 | label: 'Speech', 111 | submenu: [ 112 | { role: 'startspeaking' }, 113 | { role: 'stopspeaking' } 114 | ] 115 | } 116 | ) 117 | 118 | // Window menu 119 | template[3].submenu = [ 120 | { role: 'close' }, 121 | { role: 'minimize' }, 122 | { role: 'zoom' }, 123 | { type: 'separator' }, 124 | { role: 'front' } 125 | ] 126 | } 127 | 128 | const menu = Menu.buildFromTemplate(template) 129 | Menu.setApplicationMenu(menu) 130 | 131 | app.requestSingleInstanceLock() 132 | app.on('second-instance', (event, argv, cwd) => { 133 | app.quit() 134 | }) 135 | 136 | app.setAsDefaultProtocolClient('cabal') 137 | 138 | app.on('ready', () => { 139 | updater.start() 140 | const mainWindowState = windowStateKeeper({ 141 | defaultWidth: 800, 142 | defaultHeight: 600 143 | }) 144 | 145 | let windowOptions = { 146 | backgroundColor: '#1e1e1e', 147 | x: mainWindowState.x, 148 | y: mainWindowState.y, 149 | width: mainWindowState.width, 150 | height: mainWindowState.height, 151 | titleBarStyle: 'default', 152 | title: 'Cabal Desktop ' + app.getVersion(), 153 | webPreferences: { 154 | nodeIntegration: true 155 | } 156 | } 157 | 158 | if(platform.mac){ 159 | windowOptions.titleBarStyle = 'hiddenInset'; 160 | } 161 | 162 | win = new BrowserWindow(windowOptions) 163 | mainWindowState.manage(win) 164 | 165 | win.loadURL('file://' + path.join(__dirname, 'index.html')) 166 | Menu.setApplicationMenu(Menu.buildFromTemplate(template)) 167 | 168 | win.webContents.on('will-navigate', (event, url) => { 169 | event.preventDefault() 170 | shell.openExternal(url) 171 | }) 172 | 173 | // Protocol handler for osx 174 | app.on('open-url', (event, url) => { 175 | event.preventDefault() 176 | win.webContents.send('open-cabal-url', { url }) 177 | }) 178 | 179 | ipcMain.on('update-badge', (event, { badgeCount, showCount }) => { 180 | if (platform.mac) { 181 | const badge = showCount ? badgeCount : '•' 182 | app.dock.setBadge(badgeCount > 0 ? ('' + badge) : '') 183 | } else { 184 | app.setBadgeCount(badgeCount) 185 | } 186 | }) 187 | 188 | win.on('close', event => { 189 | if (!app.quitting) { 190 | event.preventDefault() 191 | win.hide() 192 | } 193 | if (!platform.mac) { 194 | app.quit() 195 | } 196 | }) 197 | 198 | app.on('activate', () => { 199 | win.show() 200 | }) 201 | }) 202 | 203 | app.on('window-all-closed', () => { 204 | if (!platform.mac) { 205 | app.quit() 206 | } 207 | }) 208 | 209 | app.on('before-quit', () => { 210 | app.quitting = true 211 | }) 212 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cabal-desktop", 3 | "version": "7.0.0", 4 | "description": "Cabal p2p offline-first desktop application", 5 | "scripts": { 6 | "build": "cross-env NODE_ENV=production webpack", 7 | "start:electron": "electron .", 8 | "start:webpack": "webpack", 9 | "watch": "webpack --watch", 10 | "start": "cross-env NODE_ENV=development npm-run-all --parallel start:*", 11 | "pack": "electron-builder --dir", 12 | "dist": "yarn run build && electron-builder --publish=onTagOrDraft", 13 | "dist:multi": "electron-builder -mlw", 14 | "postinstall": "electron-builder install-app-deps", 15 | "test": "cross-env NODE_ENV=test BABEL_DISABLE_CACHE=1 jest --passWithNoTests" 16 | }, 17 | "productName": "Cabal", 18 | "repository": "cabal-club/cabal-desktop", 19 | "author": { 20 | "name": "Cabal Club", 21 | "email": "github-noreply@cabal.club" 22 | }, 23 | "license": "GPL-3.0", 24 | "devDependencies": { 25 | "@babel/core": "^7.9.0", 26 | "@babel/plugin-proposal-object-rest-spread": "^7.9.5", 27 | "@babel/plugin-proposal-optional-chaining": "^7.9.0", 28 | "@babel/plugin-transform-runtime": "^7.9.0", 29 | "@babel/preset-env": "^7.9.5", 30 | "@babel/preset-react": "^7.9.4", 31 | "babel-loader": "^8.1.0", 32 | "cross-env": "^7.0.2", 33 | "css-loader": "^3.5.3", 34 | "dotenv-webpack": "^6.0.0", 35 | "electron": "7.1.13", 36 | "electron-builder": "^22.9.1", 37 | "electron-notarize": "^1.0.0", 38 | "jest": "^25.4.0", 39 | "node-sass": "^4.14.0", 40 | "npm-run-all": "^4.1.5", 41 | "prettier-standard": "^16.3.0", 42 | "sass-loader": "^8.0.2", 43 | "standard": "^14.3.3", 44 | "style-loader": "^1.2.0", 45 | "webpack": "^4.43.0", 46 | "webpack-cli": "^3.3.11", 47 | "webpack-node-externals": "^1.7.2" 48 | }, 49 | "dependencies": { 50 | "@babel/runtime": "^7.7.7", 51 | "@reduxjs/toolkit": "^1.3.5", 52 | "babel-runtime": "^6.26.0", 53 | "cabal-client": "^7.2.0", 54 | "collect-stream": "^1.2.1", 55 | "dat-encoding": "^5.0.1", 56 | "debug": "^4.1.1", 57 | "deepmerge": "^4.2.2", 58 | "del": "^5.1.0", 59 | "electron-default-menu": "^1.0.1", 60 | "electron-is-dev": "^1.2.0", 61 | "electron-prompt": "^1.5.1", 62 | "electron-reload": "^1.5.0", 63 | "electron-store": "^5.2.0", 64 | "electron-updater": "^4.3.1", 65 | "electron-window-state": "^5.0.3", 66 | "emoji-mart": "^3.0.0", 67 | "get-form-data": "^3.0.0", 68 | "lodash": "^4.17.20", 69 | "mkdirp": "^1.0.4", 70 | "moment": "^2.24.0", 71 | "mousetrap": "^1.6.5", 72 | "ms": "^2.1.2", 73 | "react": "^16.13.1", 74 | "react-blockies": "^1.4.1", 75 | "react-contexify": "^4.1.1", 76 | "react-dom": "^16.13.1", 77 | "react-redux": "^7.2.0", 78 | "redux": "^4.0.5", 79 | "redux-logger": "^3.0.6", 80 | "redux-thunk": "^2.3.0", 81 | "remark": "^12.0.0", 82 | "remark-altprot": "^1.1.0", 83 | "remark-emoji": "^2.1.0", 84 | "remark-react": "^7.0.1", 85 | "sodium-native": "^3.2.0", 86 | "strftime": "^0.10.0", 87 | "to2": "^1.0.0" 88 | }, 89 | "build": { 90 | "appId": "club.cabal.desktop", 91 | "afterSign": "scripts/notarize.js", 92 | "productName": "Cabal", 93 | "publish": [ 94 | "github" 95 | ], 96 | "protocols": [ 97 | { 98 | "name": "cabal", 99 | "schemes": [ 100 | "cabal" 101 | ] 102 | } 103 | ], 104 | "mac": { 105 | "category": "public.app-category.utilities", 106 | "entitlements": "build/entitlements.mac.plist", 107 | "entitlementsInherit": "build/entitlements.mac.plist", 108 | "gatekeeperAssess": false, 109 | "hardenedRuntime": true 110 | }, 111 | "dmg": { 112 | "sign": false, 113 | "background": "static/images/dmg-background.tiff", 114 | "contents": [ 115 | { 116 | "x": 135, 117 | "y": 200 118 | }, 119 | { 120 | "x": 405, 121 | "y": 200, 122 | "type": "link", 123 | "path": "/Applications" 124 | } 125 | ], 126 | "artifactName": "cabal-desktop-${version}-mac.${ext}" 127 | }, 128 | "linux": { 129 | "target": [ 130 | "AppImage", 131 | "snap", 132 | "deb" 133 | ], 134 | "category": "Network" 135 | }, 136 | "appImage": { 137 | "artifactName": "cabal-desktop-${version}-linux-${arch}.${ext}" 138 | }, 139 | "win": { 140 | "publisherName": "cabal" 141 | }, 142 | "nsis": { 143 | "artifactName": "cabal-desktop-${version}-windows.${ext}" 144 | }, 145 | "files": [ 146 | "**/*", 147 | "!**/node_modules/pannellum/*.{jpg,png}", 148 | "!**/node_modules/sodium-native/prebuilds/linux-arm", 149 | "!**/node_modules/*/{CHANGELOG.md,README.md,README,readme.md,readme}", 150 | "!**/node_modules/**/{test,__tests__,tests,powered-test,example,examples}", 151 | "!**/node_modules/.bin", 152 | "!**/*.{iml,o,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,xproj}", 153 | "!.editorconfig", 154 | "!**/._*", 155 | "!**/{.DS_Store,.git,.hg,.svn,CVS,RCS,SCCS,.gitignore,.gitattributes}", 156 | "!**/{__pycache__,thumbs.db,.flowconfig,.idea,.vs,.nyc_output}", 157 | "!**/{appveyor.yml,.travis.yml,circle.yml}", 158 | "!**/{npm-debug.log,yarn.lock,.yarn-integrity,.yarn-metadata.json}", 159 | "!test${/*}" 160 | ] 161 | }, 162 | "np": { 163 | "yarn": false, 164 | "private": true 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cabal-club/cabal-desktop/e19cd37cac3b8f90447c70839d6cb5792628a503/screenshot.png -------------------------------------------------------------------------------- /scripts/notarize.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config() 2 | const { notarize } = require('electron-notarize') 3 | 4 | exports.default = async function notarizing (context) { 5 | const { electronPlatformName, appOutDir } = context 6 | if (electronPlatformName !== 'darwin') { 7 | return 8 | } 9 | 10 | const appName = context.packager.appInfo.productFilename 11 | 12 | return await notarize({ 13 | ascProvider: process.env.ASCPROVIDER, 14 | appBundleId: process.env.BUNDLEID, 15 | appPath: `${appOutDir}/${appName}.app`, 16 | appleId: process.env.APPLEID, 17 | appleIdPassword: process.env.APPLEIDPASS 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /static/fonts/Noto_Sans_JP/NotoSansJP-Black.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cabal-club/cabal-desktop/e19cd37cac3b8f90447c70839d6cb5792628a503/static/fonts/Noto_Sans_JP/NotoSansJP-Black.otf -------------------------------------------------------------------------------- /static/fonts/Noto_Sans_JP/NotoSansJP-Bold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cabal-club/cabal-desktop/e19cd37cac3b8f90447c70839d6cb5792628a503/static/fonts/Noto_Sans_JP/NotoSansJP-Bold.otf -------------------------------------------------------------------------------- /static/fonts/Noto_Sans_JP/NotoSansJP-Light.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cabal-club/cabal-desktop/e19cd37cac3b8f90447c70839d6cb5792628a503/static/fonts/Noto_Sans_JP/NotoSansJP-Light.otf -------------------------------------------------------------------------------- /static/fonts/Noto_Sans_JP/NotoSansJP-Medium.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cabal-club/cabal-desktop/e19cd37cac3b8f90447c70839d6cb5792628a503/static/fonts/Noto_Sans_JP/NotoSansJP-Medium.otf -------------------------------------------------------------------------------- /static/fonts/Noto_Sans_JP/NotoSansJP-Regular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cabal-club/cabal-desktop/e19cd37cac3b8f90447c70839d6cb5792628a503/static/fonts/Noto_Sans_JP/NotoSansJP-Regular.otf -------------------------------------------------------------------------------- /static/fonts/Noto_Sans_JP/NotoSansJP-Thin.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cabal-club/cabal-desktop/e19cd37cac3b8f90447c70839d6cb5792628a503/static/fonts/Noto_Sans_JP/NotoSansJP-Thin.otf -------------------------------------------------------------------------------- /static/fonts/Noto_Sans_JP/OFL.txt: -------------------------------------------------------------------------------- 1 | This Font Software is licensed under the SIL Open Font License, Version 1.1. 2 | This license is copied below, and is also available with a FAQ at: 3 | http://scripts.sil.org/OFL 4 | 5 | 6 | ----------------------------------------------------------- 7 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 8 | ----------------------------------------------------------- 9 | 10 | PREAMBLE 11 | The goals of the Open Font License (OFL) are to stimulate worldwide 12 | development of collaborative font projects, to support the font creation 13 | efforts of academic and linguistic communities, and to provide a free and 14 | open framework in which fonts may be shared and improved in partnership 15 | with others. 16 | 17 | The OFL allows the licensed fonts to be used, studied, modified and 18 | redistributed freely as long as they are not sold by themselves. The 19 | fonts, including any derivative works, can be bundled, embedded, 20 | redistributed and/or sold with any software provided that any reserved 21 | names are not used by derivative works. The fonts and derivatives, 22 | however, cannot be released under any other type of license. The 23 | requirement for fonts to remain under this license does not apply 24 | to any document created using the fonts or their derivatives. 25 | 26 | DEFINITIONS 27 | "Font Software" refers to the set of files released by the Copyright 28 | Holder(s) under this license and clearly marked as such. This may 29 | include source files, build scripts and documentation. 30 | 31 | "Reserved Font Name" refers to any names specified as such after the 32 | copyright statement(s). 33 | 34 | "Original Version" refers to the collection of Font Software components as 35 | distributed by the Copyright Holder(s). 36 | 37 | "Modified Version" refers to any derivative made by adding to, deleting, 38 | or substituting -- in part or in whole -- any of the components of the 39 | Original Version, by changing formats or by porting the Font Software to a 40 | new environment. 41 | 42 | "Author" refers to any designer, engineer, programmer, technical 43 | writer or other person who contributed to the Font Software. 44 | 45 | PERMISSION & CONDITIONS 46 | Permission is hereby granted, free of charge, to any person obtaining 47 | a copy of the Font Software, to use, study, copy, merge, embed, modify, 48 | redistribute, and sell modified and unmodified copies of the Font 49 | Software, subject to the following conditions: 50 | 51 | 1) Neither the Font Software nor any of its individual components, 52 | in Original or Modified Versions, may be sold by itself. 53 | 54 | 2) Original or Modified Versions of the Font Software may be bundled, 55 | redistributed and/or sold with any software, provided that each copy 56 | contains the above copyright notice and this license. These can be 57 | included either as stand-alone text files, human-readable headers or 58 | in the appropriate machine-readable metadata fields within text or 59 | binary files as long as those fields can be easily viewed by the user. 60 | 61 | 3) No Modified Version of the Font Software may use the Reserved Font 62 | Name(s) unless explicit written permission is granted by the corresponding 63 | Copyright Holder. This restriction only applies to the primary font name as 64 | presented to the users. 65 | 66 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 67 | Software shall not be used to promote, endorse or advertise any 68 | Modified Version, except to acknowledge the contribution(s) of the 69 | Copyright Holder(s) and the Author(s) or with their explicit written 70 | permission. 71 | 72 | 5) The Font Software, modified or unmodified, in part or in whole, 73 | must be distributed entirely under this license, and must not be 74 | distributed under any other license. The requirement for fonts to 75 | remain under this license does not apply to any document created 76 | using the Font Software. 77 | 78 | TERMINATION 79 | This license becomes null and void if any of the above conditions are 80 | not met. 81 | 82 | DISCLAIMER 83 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 84 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 85 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 86 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 87 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 88 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 89 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 90 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 91 | OTHER DEALINGS IN THE FONT SOFTWARE. 92 | -------------------------------------------------------------------------------- /static/images/cabal-desktop-dmg-background.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cabal-club/cabal-desktop/e19cd37cac3b8f90447c70839d6cb5792628a503/static/images/cabal-desktop-dmg-background.jpg -------------------------------------------------------------------------------- /static/images/cabal-desktop-dmg-background@2x.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cabal-club/cabal-desktop/e19cd37cac3b8f90447c70839d6cb5792628a503/static/images/cabal-desktop-dmg-background@2x.jpg -------------------------------------------------------------------------------- /static/images/cabal-logo-black.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /static/images/cabal-logo-white.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /static/images/dmg-background.tiff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cabal-club/cabal-desktop/e19cd37cac3b8f90447c70839d6cb5792628a503/static/images/dmg-background.tiff -------------------------------------------------------------------------------- /static/images/icon-addcabal.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /static/images/icon-channel.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /static/images/icon-channelother.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /static/images/icon-composermeta.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /static/images/icon-composerother.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /static/images/icon-gear.svg: -------------------------------------------------------------------------------- 1 | 2 | 7 | -------------------------------------------------------------------------------- /static/images/icon-newchannel.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /static/images/icon-sidebarmenu.svg: -------------------------------------------------------------------------------- 1 | 2 | 18 | -------------------------------------------------------------------------------- /static/images/icon-status-offline.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 18 | -------------------------------------------------------------------------------- /static/images/icon-status-online.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 18 | -------------------------------------------------------------------------------- /static/images/user-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 60 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const Dotenv = require('dotenv-webpack') 2 | const nodeExternals = require('webpack-node-externals') 3 | const path = require('path') 4 | 5 | module.exports = { 6 | entry: './app/index.js', 7 | mode: 'production', 8 | target: 'electron-renderer', 9 | watch: process.env.NODE_ENV === 'development', 10 | externals: [nodeExternals()], 11 | output: { 12 | path: path.join(__dirname, 'static'), 13 | filename: 'build.js', 14 | libraryTarget: 'commonjs2' 15 | }, 16 | devtool: 'eval', 17 | node: { 18 | __dirname: true 19 | }, 20 | module: { 21 | rules: [ 22 | { 23 | test: /\.js$/, 24 | include: path.resolve(__dirname, 'app'), 25 | loader: 'babel-loader', 26 | query: { 27 | presets: ['@babel/react'], 28 | plugins: [ 29 | '@babel/plugin-proposal-object-rest-spread' 30 | ] 31 | } 32 | }, 33 | { 34 | test: /\.scss$/, 35 | use: ['style-loader', 'css-loader', 'sass-loader'] 36 | }, 37 | { 38 | test: /\.css$/, 39 | use: ['style-loader', 'css-loader'] 40 | } 41 | ] 42 | }, 43 | plugins: [ 44 | new Dotenv() 45 | ] 46 | } 47 | --------------------------------------------------------------------------------46 | 47 |50 |48 |
Initializing...49 |