├── app
├── containers
│ ├── empty.js
│ ├── avatar.js
│ ├── customLink.js
│ ├── appSettings.js
│ ├── dialog.js
│ ├── memberList.js
│ ├── layout.js
│ ├── channelPanel.js
│ ├── cabalsList.js
│ ├── addCabal.js
│ ├── cabalSettings.js
│ ├── channelBrowser.js
│ ├── profilePanel.js
│ ├── messages.js
│ ├── mainPanel.js
│ ├── write.js
│ └── sidebar.js
├── settings.js
├── platform.js
├── updater.js
├── index.js
├── app.js
├── selectors.js
├── styles
│ ├── react-contexify.css
│ └── darkmode.scss
├── reducer.js
└── actions.js
├── .env-sample
├── screenshot.png
├── static
├── images
│ ├── dmg-background.tiff
│ ├── cabal-desktop-dmg-background.jpg
│ ├── cabal-desktop-dmg-background@2x.jpg
│ ├── cabal-logo-black.svg
│ ├── cabal-logo-white.svg
│ ├── icon-status-offline.svg
│ ├── icon-status-online.svg
│ ├── icon-composermeta.svg
│ ├── icon-newchannel.svg
│ ├── icon-sidebarmenu.svg
│ ├── icon-channelother.svg
│ ├── icon-addcabal.svg
│ ├── icon-channel.svg
│ ├── icon-composerother.svg
│ ├── icon-gear.svg
│ └── user-icon.svg
└── fonts
│ └── Noto_Sans_JP
│ ├── NotoSansJP-Black.otf
│ ├── NotoSansJP-Bold.otf
│ ├── NotoSansJP-Light.otf
│ ├── NotoSansJP-Thin.otf
│ ├── NotoSansJP-Medium.otf
│ ├── NotoSansJP-Regular.otf
│ └── OFL.txt
├── .gitignore
├── .babelrc
├── .travis.yml
├── scripts
└── notarize.js
├── .vscode
└── launch.json
├── bin
└── build-multi
├── .github
└── FUNDING.yml
├── webpack.config.js
├── index.html
├── README.md
├── package.json
├── index.js
└── CHANGELOG.md
/app/containers/empty.js:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.env-sample:
--------------------------------------------------------------------------------
1 | APPLEID=
2 | APPLEIDPASS=
3 | ASCPROVIDER=
4 | BUNDLEID=
5 |
--------------------------------------------------------------------------------
/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cabal-club/cabal-desktop/HEAD/screenshot.png
--------------------------------------------------------------------------------
/static/images/dmg-background.tiff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cabal-club/cabal-desktop/HEAD/static/images/dmg-background.tiff
--------------------------------------------------------------------------------
/static/fonts/Noto_Sans_JP/NotoSansJP-Black.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cabal-club/cabal-desktop/HEAD/static/fonts/Noto_Sans_JP/NotoSansJP-Black.otf
--------------------------------------------------------------------------------
/static/fonts/Noto_Sans_JP/NotoSansJP-Bold.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cabal-club/cabal-desktop/HEAD/static/fonts/Noto_Sans_JP/NotoSansJP-Bold.otf
--------------------------------------------------------------------------------
/static/fonts/Noto_Sans_JP/NotoSansJP-Light.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cabal-club/cabal-desktop/HEAD/static/fonts/Noto_Sans_JP/NotoSansJP-Light.otf
--------------------------------------------------------------------------------
/static/fonts/Noto_Sans_JP/NotoSansJP-Thin.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cabal-club/cabal-desktop/HEAD/static/fonts/Noto_Sans_JP/NotoSansJP-Thin.otf
--------------------------------------------------------------------------------
/static/images/cabal-desktop-dmg-background.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cabal-club/cabal-desktop/HEAD/static/images/cabal-desktop-dmg-background.jpg
--------------------------------------------------------------------------------
/static/fonts/Noto_Sans_JP/NotoSansJP-Medium.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cabal-club/cabal-desktop/HEAD/static/fonts/Noto_Sans_JP/NotoSansJP-Medium.otf
--------------------------------------------------------------------------------
/static/fonts/Noto_Sans_JP/NotoSansJP-Regular.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cabal-club/cabal-desktop/HEAD/static/fonts/Noto_Sans_JP/NotoSansJP-Regular.otf
--------------------------------------------------------------------------------
/static/images/cabal-desktop-dmg-background@2x.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cabal-club/cabal-desktop/HEAD/static/images/cabal-desktop-dmg-background@2x.jpg
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | static/build.js
3 | *.sw[op]
4 | .DS*
5 | dist
6 | .idea
7 | yarn-error.log
8 | .env
9 | .nvmrc
10 | *.code-workspace
11 |
12 |
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | "@babel/env",
4 | "@babel/react"
5 | ],
6 | "plugins": ["@babel/plugin-transform-runtime", "@babel/plugin-proposal-optional-chaining"]
7 | }
--------------------------------------------------------------------------------
/static/images/cabal-logo-black.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/static/images/cabal-logo-white.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/settings.js:
--------------------------------------------------------------------------------
1 | const Store = require('electron-store')
2 |
3 | const store = new Store({ name: 'cabal-desktop-settings' })
4 | const store_defaults = {
5 | 'auto-update': true
6 | }
7 |
8 | for (var key in store_defaults) {
9 | if (store.get(key) === undefined) store.set(key, store_defaults[key])
10 | }
11 |
12 | module.exports = store
13 |
--------------------------------------------------------------------------------
/app/platform.js:
--------------------------------------------------------------------------------
1 | function isMac () {
2 | if (typeof window === 'undefined'){
3 | const process = require('process')
4 | return process.platform === 'darwin'
5 | } else {
6 | return window.navigator.platform.toLowerCase().indexOf('mac') >= 0
7 | }
8 | }
9 |
10 | var platform = {
11 | mac: isMac()
12 | }
13 |
14 | module.exports = platform
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - '10'
4 | cache: yarn
5 |
6 | os:
7 | - osx
8 | - linux
9 | - windows
10 |
11 | install:
12 | - yarn install
13 | - yarn run build
14 | - yarn run dist
15 |
16 | deploy:
17 | draft: true
18 | edge: true
19 | file_glob: true
20 | overwrite: true
21 | provider: releases
22 | on:
23 | tags: true
24 | all_branches: true
25 |
--------------------------------------------------------------------------------
/app/containers/avatar.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import Identicon from 'react-blockies'
3 |
4 | export default class Avatar extends Component {
5 | render () {
6 | return (
7 |
8 |
16 | Are you sure you want to leave this cabal?
17 |
18 | This can’t be undone.
19 |
21 | 27 | 30 |
31 | 36 |
52 | open-source decentralized private chat 53 |
54 |` and headings. 180 | 181 | ## [2.0.3] - 2019-06-18 182 | ### Added 183 | - UI to show and set channel topics. 184 | - Display currently connected peer count. 185 | - Settings screen for future features and a button to remove the cabal from the client. 186 | - Slash commands for `/help`, `/join`/`/j`, `/motd` (message of the day), `/nick`/`/n`, `/topic`, and `/remove` (for removing a cabal from the client). -------------------------------------------------------------------------------- /app/styles/darkmode.scss: -------------------------------------------------------------------------------- 1 | /* 2 | ------------------------------------------------------ 3 | DARKMODE 4 | ------------------------------------------------------ 5 | 6 | This is a temporary hack so we can enjoy darkmode in the short term until we finish 7 | a better approach for customizing styles in `cabal-ui` :D 8 | */ 9 | 10 | $backgroundColor: #16161d; 11 | $backgroundColor2: #222; 12 | $backgroundColor3: #333; 13 | $borderColor: #444; 14 | $borderColor2: #888; 15 | $borderColor3: #ddd; 16 | $borderColor4: #fff; 17 | $buttonTextColor: #ccc; 18 | $buttonBackgroundColor: #1f0f50; 19 | $highlightColor: #9571D6; 20 | $linkColor: #4393e6; 21 | $textColor: #fff; 22 | $textColor2: #aaa; 23 | 24 | 25 | .client.darkmode { 26 | color: $textColor2; 27 | 28 | a { 29 | color: $linkColor; 30 | } 31 | 32 | .button { 33 | background-color: $buttonBackgroundColor; 34 | border-color: $highlightColor; 35 | color: $highlightColor; 36 | } 37 | .button:hover { 38 | border-color: $highlightColor; 39 | color: $buttonTextColor; 40 | } 41 | 42 | // .client__cabals { 43 | // background-color: $backgroundColor; 44 | // border-right: 1px solid $borderColor; 45 | // color: $textColor; 46 | // } 47 | 48 | // .client__cabals .switcher__item { 49 | // border: 2px solid $borderColor3; 50 | // color: $textColor; 51 | // } 52 | 53 | // .client__cabals .switcher__item:hover { 54 | // border: 2px solid rgba(255, 255, 255, 0.5); 55 | // color: $textColor2; 56 | // } 57 | 58 | // .client__cabals .switcher__item--active { 59 | // background-color: $highlightColor; 60 | // border: 2px solid $highlightColor; 61 | // color: $textColor !important; 62 | // } 63 | 64 | // .client__cabals .unreadIndicator { 65 | // background: $highlightColor; 66 | // } 67 | 68 | // .client__cabals .client__cabals__footer { 69 | // .settingsButton { 70 | // &:hover { 71 | // color: $textColor; 72 | // } 73 | // } 74 | // } 75 | 76 | .client__sidebar { 77 | background-color: $backgroundColor; 78 | color: $textColor; 79 | 80 | .session .session__meta h2 { 81 | color: $textColor2; 82 | } 83 | 84 | .session .session__configuration { 85 | // background-color: rgba(255, 255, 255, 0); 86 | } 87 | 88 | .collection { 89 | border-top-color: $borderColor; 90 | } 91 | 92 | .collection .collection__heading .collection__heading__title { 93 | color: $textColor2; 94 | } 95 | 96 | .collection .collection__item:hover { 97 | background-color: $backgroundColor2; 98 | } 99 | 100 | .collection .collection__item .collection__item__content { 101 | color: $textColor2; 102 | } 103 | 104 | .collection .collection__item .collection__item__messagesUnreadCount { 105 | color: $textColor; 106 | background-color: $highlightColor; 107 | } 108 | 109 | .collection .collection__item.active .collection__item__content, 110 | .collection .collection__item .collection__item__content.active { 111 | color: $textColor; 112 | } 113 | } 114 | 115 | .client__main { 116 | background-color: $backgroundColor; 117 | 118 | .window { 119 | border-left: 1px solid $borderColor; 120 | } 121 | 122 | .window__header { 123 | background-color: $backgroundColor; 124 | 125 | &.private { 126 | background-color: #693afa50; 127 | } 128 | } 129 | 130 | .channel-meta { 131 | border-bottom: 1px solid $borderColor; 132 | 133 | .channel-meta__data__details h1 { 134 | color: $textColor; 135 | } 136 | 137 | .channel-meta__data__details h2 { 138 | color: $textColor2; 139 | } 140 | 141 | .channel-meta__other .channel-meta__other__more { 142 | filter: invert(100%); 143 | } 144 | } 145 | .messages__item { 146 | &:hover { 147 | background-color: $backgroundColor2; 148 | } 149 | } 150 | 151 | .messages__item--system { 152 | background: $backgroundColor; 153 | } 154 | 155 | .messages__item__metadata { 156 | .messages__item__metadata__name { 157 | color: $textColor; 158 | } 159 | 160 | span { 161 | color: $textColor2; 162 | } 163 | 164 | div.text { 165 | pre { 166 | background-color: $backgroundColor3; 167 | } 168 | 169 | p code { 170 | background-color: $backgroundColor3; 171 | } 172 | 173 | blockquote { 174 | background: $backgroundColor3; 175 | } 176 | } 177 | 178 | a.link { 179 | color: $linkColor; 180 | 181 | &:hover { 182 | color: $linkColor; 183 | } 184 | } 185 | 186 | .cabal-settings__close { 187 | filter: invert(100%); 188 | } 189 | } 190 | 191 | .cabal-settings__item { 192 | border-bottom: 1px solid $borderColor; 193 | 194 | .cabal-settings__item-input { 195 | input { 196 | color: $textColor; 197 | background-color: $backgroundColor3; 198 | 199 | &.cabalKey { 200 | 201 | } 202 | } 203 | } 204 | } 205 | 206 | .cabal-settings__item-label-description { 207 | color: $textColor2; 208 | } 209 | 210 | .composer { 211 | background-color: $backgroundColor3; 212 | border: 2px solid $borderColor; 213 | 214 | .composer__input textarea { 215 | color: $textColor; 216 | background-color: $backgroundColor3; 217 | } 218 | 219 | &:hover, &:active { 220 | border-color: $borderColor2; 221 | } 222 | 223 | .composer__meta { 224 | border-right: 2px solid $borderColor; 225 | } 226 | 227 | .composer__other { 228 | filter: invert(100%) 229 | } 230 | } 231 | } 232 | 233 | .modalScreen { 234 | background: $backgroundColor; 235 | 236 | .modalScreen__header { 237 | .modalScreen__close { 238 | background-color: $backgroundColor; 239 | color: $textColor2; 240 | 241 | &:hover { 242 | color: $textColor; 243 | } 244 | } 245 | } 246 | } 247 | 248 | .modal-overlay { 249 | background-color: $backgroundColor; 250 | } 251 | 252 | .modal-overlay .modal { 253 | background-color: $backgroundColor; 254 | color: $textColor; 255 | } 256 | 257 | .modal-overlay .modal li a, 258 | .modal-overlay .modal li button { 259 | color: $textColor2; 260 | background: none; 261 | } 262 | 263 | .modal-overlay .modal li a:hover, 264 | .modal-overlay .modal li button:hover { 265 | background-color: $backgroundColor3; 266 | } 267 | 268 | ::-webkit-scrollbar-thumb { 269 | background-color: $backgroundColor3; 270 | } 271 | 272 | .sidebar ::-webkit-scrollbar-thumb { 273 | background-color: $backgroundColor3; 274 | } 275 | 276 | .messages__date__divider { 277 | h2 { 278 | color: $borderColor; 279 | } 280 | 281 | h2:before, 282 | h2:after { 283 | background-color: $borderColor; 284 | } 285 | } 286 | 287 | .channelBrowser__sectionTitle { 288 | border-bottom: 1px solid $borderColor; 289 | } 290 | 291 | .channelBrowser__row { 292 | border-bottom: 1px solid $borderColor; 293 | 294 | &:hover { 295 | background-color: $backgroundColor2; 296 | } 297 | 298 | .topic { 299 | color: $highlightColor; 300 | } 301 | 302 | .members { 303 | color: $textColor2; 304 | } 305 | } 306 | 307 | 308 | .panel { 309 | background-color: $backgroundColor; 310 | border-left-color: $borderColor; 311 | 312 | .panel__header { 313 | border-bottom-color: $borderColor; 314 | 315 | .close { 316 | filter: invert(100%); 317 | } 318 | } 319 | 320 | .panel__content { 321 | .collection__item { 322 | &:hover { 323 | background: $backgroundColor3; 324 | } 325 | } 326 | } 327 | 328 | .section__header { 329 | border-top-color: $borderColor; 330 | } 331 | } 332 | 333 | .profilePanel { 334 | .panel__header { 335 | background: $backgroundColor; 336 | } 337 | 338 | .avatar__online__indicator { 339 | border-color: $backgroundColor; 340 | } 341 | 342 | .help__text { 343 | color: $textColor2; 344 | } 345 | } 346 | } 347 | -------------------------------------------------------------------------------- /app/containers/messages.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { connect } from 'react-redux' 3 | import moment from 'moment' 4 | 5 | import { 6 | getUser, 7 | showProfilePanel 8 | } from '../actions' 9 | import Avatar from './avatar' 10 | import { currentChannelMessagesSelector, currentChannelSelector } from '../selectors' 11 | 12 | const mapStateToProps = state => ({ 13 | addr: state.currentCabal, 14 | messages: currentChannelMessagesSelector(state), 15 | channel: currentChannelSelector(state) 16 | }) 17 | 18 | const mapDispatchToProps = dispatch => ({ 19 | getUser: ({ key }) => dispatch(getUser({ key })), 20 | showProfilePanel: ({ addr, userKey }) => dispatch(showProfilePanel({ addr, userKey })) 21 | }) 22 | 23 | function MessagesContainer(props) { 24 | const onClickProfile = (user) => { 25 | props.showProfilePanel({ 26 | addr: props.addr, 27 | userKey: user.key 28 | }) 29 | } 30 | 31 | const renderDate = (time) => { 32 | return ( 33 | 34 | {time.short} 35 | {time.long} 36 | 37 | ) 38 | } 39 | 40 | const seen = {} 41 | const messages = (props.messages ?? []).filter((message) => { 42 | const messageId = message.key + message.message.seq 43 | if (typeof seen[messageId] === 'undefined') { 44 | seen[messageId] = true 45 | return true 46 | } 47 | return false 48 | }) 49 | 50 | let lastDividerDate = moment() // hold the time of the message for which divider was last added 51 | 52 | if (messages.length === 0 && props.channel !== '!status') { 53 | return ( 54 |55 | This is a new channel. Send a message to start things off! 56 |57 | ) 58 | } else { 59 | const defaultSystemName = 'Cabalbot' 60 | let prevMessage = {} 61 | return ( 62 | <> 63 |64 | {messages.map((message) => { 65 | // Hide messages from hidden users 66 | const user = message.user 67 | if (user && user.isHidden()) return null 68 | 69 | const enriched = message.enriched 70 | // avoid comaprison with other types of message than chat/text 71 | 72 | const repeatedAuthor = message.key === prevMessage.key && prevMessage.type === 'chat/text' 73 | const printDate = moment(enriched.time) 74 | const formattedTime = { 75 | short: printDate.format('h:mm A'), 76 | long: printDate.format('LL') 77 | } 78 | // divider only needs to be added if its a normal message 79 | // and if day has changed since the last divider 80 | const showDivider = message.content && !lastDividerDate.isSame(printDate, 'day') 81 | if (showDivider) { 82 | lastDividerDate = printDate 83 | } 84 | let item = () 85 | prevMessage = message 86 | if (message.type === 'status') { 87 | item = ( 88 |180 | > 181 | ) 182 | } 183 | } 184 | 185 | export default connect(mapStateToProps, mapDispatchToProps)(MessagesContainer) 186 | -------------------------------------------------------------------------------- /app/containers/mainPanel.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { ipcRenderer } from 'electron' 3 | import { connect } from 'react-redux' 4 | import prompt from 'electron-prompt' 5 | import { debounce } from 'lodash' 6 | import { 7 | changeScreen, 8 | hideEmojiPicker, 9 | leaveChannel, 10 | saveCabalSettings, 11 | setChannelTopic, 12 | showCabalSettings, 13 | showChannelPanel, 14 | viewCabal 15 | } from '../actions' 16 | import CabalSettings from './cabalSettings' 17 | import ChannelBrowser from './channelBrowser' 18 | import WriteContainer from './write' 19 | import MessagesContainer from './messages' 20 | import { currentChannelMemberCountSelector } from '../selectors' 21 | 22 | const mapStateToProps = state => { 23 | const cabal = state.cabals[state.currentCabal] 24 | const addr = cabal.addr 25 | return { 26 | addr, 27 | cabal, 28 | cabals: state.cabals, 29 | cabalSettingsVisible: state.cabalSettingsVisible, 30 | channelBrowserVisible: state.channelBrowserVisible, 31 | channelMemberCount: currentChannelMemberCountSelector(state), 32 | emojiPickerVisible: state.emojiPickerVisible, 33 | settings: state.cabalSettings[addr] || {} 34 | } 35 | } 36 | 37 | const mapDispatchToProps = dispatch => ({ 38 | changeScreen: ({ screen, addr }) => dispatch(changeScreen({ screen, addr })), 39 | hideEmojiPicker: () => dispatch(hideEmojiPicker()), 40 | leaveChannel: ({ addr, channel }) => dispatch(leaveChannel({ addr, channel })), 41 | saveCabalSettings: ({ addr, settings }) => dispatch(saveCabalSettings({ addr, settings })), 42 | setChannelTopic: ({ addr, channel, topic }) => 43 | dispatch(setChannelTopic({ addr, channel, topic })), 44 | showCabalSettings: ({ addr }) => dispatch(showCabalSettings({ addr })), 45 | showChannelPanel: ({ addr }) => dispatch(showChannelPanel({ addr })), 46 | viewCabal: ({ addr }) => dispatch(viewCabal({ addr })) 47 | }) 48 | 49 | class MainPanel extends Component { 50 | constructor (props) { 51 | super(props) 52 | this.state = { 53 | showScrollToBottom: false, 54 | shouldAutoScroll: true 55 | } 56 | this.refScrollContainer = null 57 | this.handleOpenCabalUrl = this.handleOpenCabalUrl.bind(this) 58 | this.setScrollToBottomButtonStatus = this.setScrollToBottomButtonStatus.bind(this) 59 | this.scrollToBottom = this.scrollToBottom.bind(this) 60 | this.onScrollMessagesUpdateBottomStatus = debounce(this.setScrollToBottomButtonStatus, 500, { 61 | leading: true, 62 | trailing: true 63 | }) 64 | } 65 | 66 | addEventListeners () { 67 | const self = this 68 | this.refScrollContainer?.addEventListener( 69 | 'scroll', 70 | self.onScrollMessages.bind(this) 71 | ) 72 | this.refScrollContainer?.addEventListener( 73 | 'scroll', 74 | self.onScrollMessagesUpdateBottomStatus.bind(this) 75 | ) 76 | } 77 | 78 | removeEventListeners () { 79 | const self = this 80 | this.refScrollContainer?.removeEventListener( 81 | 'scroll', 82 | self.onScrollMessages.bind(this) 83 | ) 84 | this.refScrollContainer?.removeEventListener( 85 | 'scroll', 86 | self.onScrollMessagesUpdateBottomStatus.bind(this) 87 | ) 88 | } 89 | 90 | componentDidMount () { 91 | this.addEventListeners() 92 | ipcRenderer.on('open-cabal-url', (event, arg) => { 93 | this.handleOpenCabalUrl(arg) 94 | }) 95 | } 96 | 97 | setScrollToBottomButtonStatus () { 98 | const totalHeight = this.refScrollContainer?.scrollHeight 99 | const scrolled = this.refScrollContainer?.scrollTop + 100 100 | const containerHeight = this.refScrollContainer?.offsetHeight 101 | if (scrolled < totalHeight - containerHeight) { 102 | this.setState({ 103 | showScrollToBottom: true 104 | }) 105 | } else if (scrolled >= totalHeight - containerHeight) { 106 | this.setState({ 107 | showScrollToBottom: false 108 | }) 109 | } 110 | 111 | this.scrollToBottom() 112 | } 113 | 114 | componentWillUnmount () { 115 | this.removeEventListeners() 116 | } 117 | 118 | componentDidUpdate (prevProps) { 119 | const changedScreen = ( 120 | (prevProps.channelBrowserVisible !== this.props.channelBrowserVisible) || 121 | (prevProps.cabalSettingsVisible !== this.props.cabalSettingsVisible) || 122 | (prevProps.settings?.currentChannel !== this.props.settings?.currentChannel) 123 | ) 124 | if (changedScreen) { 125 | this.removeEventListeners() 126 | this.addEventListeners() 127 | this.scrollToBottom(true) 128 | } 129 | if (prevProps.cabal !== this.props.cabal) { 130 | if (document.hasFocus()) { 131 | this.scrollToBottom() 132 | } else { 133 | this.setScrollToBottomButtonStatus() 134 | } 135 | } 136 | } 137 | 138 | onClickTopic () { 139 | prompt({ 140 | title: 'Set channel topic', 141 | label: 'New topic', 142 | value: this.props.cabal.topic, 143 | type: 'input' 144 | }) 145 | .then(topic => { 146 | if (topic && topic.trim().length > 0) { 147 | this.props.cabal.topic = topic 148 | this.props.setChannelTopic({ 149 | addr: this.props.cabal.addr, 150 | channel: this.props.cabal.channel, 151 | topic 152 | }) 153 | } 154 | }) 155 | .catch(() => { 156 | console.log('cancelled new topic') 157 | }) 158 | } 159 | 160 | onToggleFavoriteChannel (channelName) { 161 | const favorites = [...(this.props.settings['favorite-channels'] || [])] 162 | const index = favorites.indexOf(channelName) 163 | if (index > -1) { 164 | favorites.splice(index, 1) 165 | } else { 166 | favorites.push(channelName) 167 | } 168 | const settings = this.props.settings 169 | settings['favorite-channels'] = favorites 170 | this.props.saveCabalSettings({ addr: this.props.cabal.addr, settings }) 171 | } 172 | 173 | handleOpenCabalUrl ({ url = '' }) { 174 | const addr = url.replace('cabal://', '').trim() 175 | if (this.props.cabals[addr]) { 176 | this.props.viewCabal({ addr }) 177 | } else { 178 | this.props.changeScreen({ screen: 'addCabal', addr: url }) 179 | } 180 | } 181 | 182 | onScrollMessages (event) { 183 | var element = event.target 184 | var shouldAutoScroll = this.state.shouldAutoScroll 185 | if (element.scrollHeight - element.scrollTop === element.clientHeight) { 186 | shouldAutoScroll = true 187 | } else { 188 | shouldAutoScroll = false 189 | } 190 | this.setState({ 191 | shouldAutoScroll: shouldAutoScroll 192 | }) 193 | } 194 | 195 | hideModals () { 196 | if (this.props.emojiPickerVisible) { 197 | this.props.hideEmojiPicker() 198 | } 199 | } 200 | 201 | scrollToBottom (force) { 202 | if (!force && !this.state.shouldAutoScroll) return 203 | this.setState({ 204 | shouldAutoScroll: true 205 | }) 206 | var refScrollContainer = document.querySelector('.window__main') 207 | if (refScrollContainer) { 208 | refScrollContainer.scrollTop = refScrollContainer.scrollHeight 209 | } 210 | } 211 | 212 | showCabalSettings (addr) { 213 | this.props.showCabalSettings({ addr }) 214 | } 215 | 216 | showChannelPanel (addr) { 217 | this.props.showChannelPanel({ addr }) 218 | } 219 | 220 | render () { 221 | const { cabal, channelMemberCount, settings } = this.props 222 | var self = this 223 | 224 | if (!cabal) { 225 | return ( 226 | <> 227 | 228 | > 229 | ) 230 | } else if (this.props.channelBrowserVisible) { 231 | return89 |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 |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/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 |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 | 285 | {showScrollToBottom && ( 286 |325 | ) 326 | } 327 | } 328 | 329 | const WriteContainer = connect(mapStateToProps, mapDispatchToProps)(writeScreen) 330 | 331 | export default WriteContainer 332 | -------------------------------------------------------------------------------- /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 |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 |227 |393 | ) 394 | } 395 | } 396 | 397 | const Sidebar = connect(mapStateToProps, mapDispatchToProps)(SidebarScreen) 398 | 399 | export default Sidebar 400 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------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 |