diff --git a/background.html b/background.html index eeda839c3..e3cc87c46 100644 --- a/background.html +++ b/background.html @@ -822,6 +822,8 @@ + + diff --git a/js/background.js b/js/background.js index 82f6c44b6..ba387bc29 100644 --- a/js/background.js +++ b/js/background.js @@ -851,6 +851,18 @@ } }); + Whisper.events.on('addModerators', async groupConvo => { + if (appView) { + appView.showAddModeratorsDialog(groupConvo); + } + }); + + Whisper.events.on('removeModerators', async groupConvo => { + if (appView) { + appView.showRemoveModeratorsDialog(groupConvo); + } + }); + Whisper.events.on( 'publicChatInvitationAccepted', async (serverAddress, channelId) => { diff --git a/js/modules/signal.js b/js/modules/signal.js index bf214dc98..52d7a7fc2 100644 --- a/js/modules/signal.js +++ b/js/modules/signal.js @@ -57,6 +57,17 @@ const { const { InviteFriendsDialog, } = require('../../ts/components/conversation/InviteFriendsDialog'); + +const { + ManageModeratorsDialog, +} = require('../../ts/components/conversation/ManageModeratorsDialog'); +const { + AddModeratorsDialog, +} = require('../../ts/components/conversation/ModeratorsAddDialog'); +const { + RemoveModeratorsDialog, +} = require('../../ts/components/conversation/ModeratorsRemoveDialog'); + const { GroupInvitation, } = require('../../ts/components/conversation/GroupInvitation'); @@ -242,6 +253,9 @@ exports.setup = (options = {}) => { ConfirmDialog, UpdateGroupDialog, InviteFriendsDialog, + ManageModeratorsDialog, + AddModeratorsDialog, + RemoveModeratorsDialog, GroupInvitation, BulkEdit, MediaGallery, diff --git a/js/views/app_view.js b/js/views/app_view.js index dc183c445..3dd109985 100644 --- a/js/views/app_view.js +++ b/js/views/app_view.js @@ -266,5 +266,13 @@ const dialog = new Whisper.InviteFriendsDialogView(groupConvo); this.el.append(dialog.el); }, + showAddModeratorsDialog(groupConvo) { + const dialog = new Whisper.AddModeratorsDialogView(groupConvo); + this.el.append(dialog.el); + }, + showRemoveModeratorsDialog(groupConvo) { + const dialog = new Whisper.RemoveModeratorsDialogView(groupConvo); + this.el.append(dialog.el); + }, }); })(); diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index 1cd8b8d48..8af78423d 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -299,6 +299,14 @@ window.Whisper.events.trigger('inviteFriends', this.model); }, + onAddModerators: () => { + window.Whisper.events.trigger('addModerators', this.model); + }, + + onRemoveModerators: () => { + window.Whisper.events.trigger('removeModerators', this.model); + }, + onShowUserDetails: pubkey => { if (this.model.isPrivate()) { window.Whisper.events.trigger('onShowUserDetails', { diff --git a/js/views/moderators_add_dialog_view.js b/js/views/moderators_add_dialog_view.js new file mode 100644 index 000000000..df304ca0a --- /dev/null +++ b/js/views/moderators_add_dialog_view.js @@ -0,0 +1,66 @@ +/* global Whisper, log */ + +// eslint-disable-next-line func-names +(function() { + 'use strict'; + + window.Whisper = window.Whisper || {}; + + Whisper.AddModeratorsDialogView = Whisper.View.extend({ + className: 'loki-dialog modal', + async initialize(convo) { + this.close = this.close.bind(this); + this.onSubmit = this.onSubmit.bind(this); + + this.chatName = convo.get('name'); + this.chatServer = convo.get('server'); + this.channelId = convo.get('channelId'); + + // get current list of moderators + this.channelAPI = await convo.getPublicSendData(); + const modPubKeys = await this.channelAPI.getModerators(); + const convos = window.getConversations().models; + + // private friends (not you) that aren't already moderators + const friends = convos.filter( + d => + !!d && + d.isFriend() && + d.isPrivate() && + !d.isMe() && + !modPubKeys.includes(d.id) + ); + + this.friends = friends; + + this.$el.focus(); + this.render(); + }, + render() { + const view = new Whisper.ReactWrapperView({ + className: 'add-moderators-dialog', + Component: window.Signal.Components.AddModeratorsDialog, + props: { + friendList: this.friends, + chatName: this.chatName, + onSubmit: this.onSubmit, + onClose: this.close, + }, + }); + + this.$el.append(view.el); + return this; + }, + close() { + this.remove(); + }, + async onSubmit(pubKeys) { + log.info(`asked to add ${pubKeys}`); + const res = await this.channelAPI.serverAPI.addModerators(pubKeys); + if (res !== true) { + // we have errors, deal with them... + // how? + } + }, + }); +})(); diff --git a/js/views/moderators_remove_dialog_view.js b/js/views/moderators_remove_dialog_view.js new file mode 100644 index 000000000..166d9f9dd --- /dev/null +++ b/js/views/moderators_remove_dialog_view.js @@ -0,0 +1,64 @@ +/* global Whisper */ + +// eslint-disable-next-line func-names +(function() { + 'use strict'; + + window.Whisper = window.Whisper || {}; + + Whisper.RemoveModeratorsDialogView = Whisper.View.extend({ + className: 'loki-dialog modal', + async initialize(convo) { + this.close = this.close.bind(this); + this.onSubmit = this.onSubmit.bind(this); + + this.chatName = convo.get('name'); + this.chatServer = convo.get('server'); + this.channelId = convo.get('channelId'); + + // get current list of moderators + this.channelAPI = await convo.getPublicSendData(); + const modPubKeys = await this.channelAPI.getModerators(); + const convos = window.getConversations().models; + const moderators = modPubKeys + .map( + pubKey => + convos.find(c => c.id === pubKey) || { + id: pubKey, // memberList need a key + authorPhoneNumber: pubKey, + } + ) + .filter(c => !!c); + + this.mods = moderators; + + this.$el.focus(); + this.render(); + }, + render() { + const view = new Whisper.ReactWrapperView({ + className: 'remove-moderators-dialog', + Component: window.Signal.Components.RemoveModeratorsDialog, + props: { + modList: this.mods, + onSubmit: this.onSubmit, + onClose: this.close, + chatName: this.chatName, + }, + }); + + this.$el.append(view.el); + return this; + }, + close() { + this.remove(); + }, + async onSubmit(pubKeys) { + const res = await this.channelAPI.serverAPI.removeModerators(pubKeys); + if (res !== true) { + // we have errors, deal with them... + // how? + } + }, + }); +})(); diff --git a/stylesheets/_mentions.scss b/stylesheets/_mentions.scss index 882470bc5..65576f7c6 100644 --- a/stylesheets/_mentions.scss +++ b/stylesheets/_mentions.scss @@ -30,6 +30,8 @@ } .invite-friends-dialog, +.add-moderators-dialog, +.remove-moderators-dialog, .create-group-dialog { .content { max-width: 100% !important; @@ -50,6 +52,8 @@ } .create-group-dialog, +.add-moderators-dialog, +.remove-moderators-dialog, .invite-friends-dialog { .no-friends { text-align: center; @@ -61,6 +65,8 @@ } .create-group-dialog, +.add-moderators-dialog, +.remove-moderators-dialog, .edit-profile-dialog { .error-message { text-align: center; @@ -129,6 +135,8 @@ .member-list-container, .create-group-dialog, +.add-moderators-dialog, +.remove-moderators-dialog, .invite-friends-dialog { .member-item { padding: 4px; @@ -182,6 +190,8 @@ .dark-theme { .member-list-container, .create-group-dialog, + .add-moderators-dialog, + .remove-moderators-dialog, .invite-friends-dialog { .member-item { &:hover:not(.member-selected) { @@ -203,6 +213,12 @@ } } +.add-moderators-dialog { + .module-main-header__search__input { + color: rgb(32, 32, 32); + } +} + .module-conversation-list-item--mentioned-us { border-left: 4px solid #ffb000 !important; } diff --git a/ts/components/conversation/ConversationHeader.tsx b/ts/components/conversation/ConversationHeader.tsx index 5c79eadf3..9c710e03e 100644 --- a/ts/components/conversation/ConversationHeader.tsx +++ b/ts/components/conversation/ConversationHeader.tsx @@ -67,8 +67,10 @@ interface Props { onUpdateGroup: () => void; onLeaveGroup: () => void; - + onAddModerators: () => void; + onRemoveModerators: () => void; onInviteFriends: () => void; + onShowUserDetails?: (userPubKey: string) => void; i18n: LocalizerType; @@ -240,6 +242,8 @@ export class ConversationHeader extends React.Component { onCopyPublicKey, onUpdateGroup, onLeaveGroup, + onAddModerators, + onRemoveModerators, onInviteFriends, } = this.props; @@ -255,6 +259,14 @@ export class ConversationHeader extends React.Component { {isPrivateGroup || amMod ? ( {i18n('updateGroup')} ) : null} + {amMod ? ( + {i18n('addModerators')} + ) : null} + {amMod ? ( + + {i18n('removeModerators')} + + ) : null} {isPrivateGroup ? ( {i18n('leaveGroup')} ) : null} diff --git a/ts/components/conversation/ModeratorsAddDialog.tsx b/ts/components/conversation/ModeratorsAddDialog.tsx new file mode 100644 index 000000000..df1f0311a --- /dev/null +++ b/ts/components/conversation/ModeratorsAddDialog.tsx @@ -0,0 +1,217 @@ +import React from 'react'; +import { Contact, MemberList } from './MemberList'; +import { cleanSearchTerm } from '../../util/cleanSearchTerm'; + +interface Props { + friendList: Array; + chatName: string; + onSubmit: any; + onClose: any; +} + +declare global { + interface Window { + i18n: any; + } +} + +interface State { + friendList: Array; + inputBoxValue: string; +} + +export class AddModeratorsDialog extends React.Component { + private readonly updateSearchBound: ( + event: React.FormEvent + ) => void; + private readonly inputRef: React.RefObject; + + constructor(props: any) { + super(props); + + this.updateSearchBound = this.updateSearch.bind(this); + this.onMemberClicked = this.onMemberClicked.bind(this); + this.add = this.add.bind(this); + this.closeDialog = this.closeDialog.bind(this); + this.onClickOK = this.onClickOK.bind(this); + this.onKeyUp = this.onKeyUp.bind(this); + this.inputRef = React.createRef(); + + let friends = this.props.friendList; + friends = friends.map(d => { + const lokiProfile = d.getLokiProfile(); + const name = lokiProfile ? lokiProfile.displayName : 'Anonymous'; + + // TODO: should take existing members into account + const existingMember = false; + + return { + id: d.id, + authorPhoneNumber: d.id, + authorProfileName: name, + selected: false, + authorName: name, + authorColor: d.getColor(), + checkmarked: false, + existingMember, + }; + }); + this.state = { + friendList: friends, + inputBoxValue: '', + }; + + window.addEventListener('keyup', this.onKeyUp); + } + + public updateSearch(event: React.FormEvent) { + const searchTerm = event.currentTarget.value; + + const cleanedTerm = cleanSearchTerm(searchTerm); + if (!cleanedTerm) { + return; + } + + this.setState(state => { + return { + ...state, + inputBoxValue: searchTerm, + }; + }); + } + public add() { + // if we have valid data + if (this.state.inputBoxValue.length > 64) { + const weHave = this.state.friendList.some( + user => user.authorPhoneNumber === this.state.inputBoxValue + ); + if (!weHave) { + // lookup to verify it's registered? + + // convert pubKey into local object... + const friends = this.state.friendList; + friends.push({ + id: this.state.inputBoxValue, + authorPhoneNumber: this.state.inputBoxValue, + authorProfileName: this.state.inputBoxValue, + authorAvatarPath: '', + selected: true, + authorName: this.state.inputBoxValue, + authorColor: '#000000', + checkmarked: true, + existingMember: false, + }); + this.setState(state => { + return { + ...state, + friendList: friends, + }; + }); + } + // + } + // clear + if (this.inputRef.current) { + this.inputRef.current.value = ''; + } + this.setState(state => { + return { + ...state, + inputBoxValue: '', + }; + }); + } + + public render() { + const i18n = window.i18n; + const titleText = `${i18n('addModerators')} ${this.props.chatName}`; + + const hasFriends = this.state.friendList.length !== 0; + + return ( +
+

{titleText}

+ Add Moderator: + + + From friends: +
+ +
+ {hasFriends ? null : ( +

{i18n('noFriendsToAdd')}

+ )} +
+ + +
+
+ ); + } + + private onClickOK() { + this.add(); // process inputBox + const selectedFriends = this.state.friendList + .filter(d => d.checkmarked) + .map(d => d.id); + if (selectedFriends.length > 0) { + this.props.onSubmit(selectedFriends); + } + + this.closeDialog(); + } + + private onKeyUp(event: any) { + switch (event.key) { + case 'Enter': + this.onClickOK(); + break; + case 'Esc': + case 'Escape': + this.closeDialog(); + break; + default: + } + } + + private closeDialog() { + window.removeEventListener('keyup', this.onKeyUp); + + this.props.onClose(); + } + + private onMemberClicked(selected: any) { + const updatedFriends = this.state.friendList.map(member => { + if (member.id === selected.id) { + return { ...member, checkmarked: !member.checkmarked }; + } else { + return member; + } + }); + + this.setState(state => { + return { + ...state, + friendList: updatedFriends, + }; + }); + } +} diff --git a/ts/components/conversation/ModeratorsRemoveDialog.tsx b/ts/components/conversation/ModeratorsRemoveDialog.tsx new file mode 100644 index 000000000..33a32aced --- /dev/null +++ b/ts/components/conversation/ModeratorsRemoveDialog.tsx @@ -0,0 +1,139 @@ +import React from 'react'; +import { Contact, MemberList } from './MemberList'; + +interface Props { + modList: Array; + chatName: string; + onSubmit: any; + onClose: any; +} + +declare global { + interface Window { + i18n: any; + } +} + +interface State { + modList: Array; +} + +export class RemoveModeratorsDialog extends React.Component { + constructor(props: any) { + super(props); + + this.onModClicked = this.onModClicked.bind(this); + this.closeDialog = this.closeDialog.bind(this); + this.onClickOK = this.onClickOK.bind(this); + this.onKeyUp = this.onKeyUp.bind(this); + + let mods = this.props.modList; + mods = mods.map(d => { + let name = ''; + if (d.getLokiProfile) { + const lokiProfile = d.getLokiProfile(); + name = lokiProfile ? lokiProfile.displayName : 'Anonymous'; + } + const authorColor = d.getColor ? d.getColor() : '#000000'; + // TODO: should take existing members into account + const existingMember = false; + + return { + id: d.id, + authorPhoneNumber: d.id, + authorProfileName: name, + selected: false, + authorName: name, + authorColor, + checkmarked: true, + existingMember, + }; + }); + this.state = { + modList: mods, + }; + + window.addEventListener('keyup', this.onKeyUp); + } + + public render() { + const i18n = window.i18n; + const titleText = `${i18n('removeModerators')} ${this.props.chatName}`; + + const hasMods = this.state.modList.length !== 0; + + return ( +
+

{titleText}

+ Existing moderators: +
+ +
+ {hasMods ? null : ( +

{i18n('noModeratorsToRemove')}

+ )} +
+ + +
+
+ ); + } + + private onClickOK() { + const removedMods = this.state.modList + .filter(d => !d.checkmarked) + .map(d => d.id); + + if (removedMods.length > 0) { + this.props.onSubmit(removedMods); + } + + this.closeDialog(); + } + + private onKeyUp(event: any) { + switch (event.key) { + case 'Enter': + this.onClickOK(); + break; + case 'Esc': + case 'Escape': + this.closeDialog(); + break; + default: + } + } + + private closeDialog() { + window.removeEventListener('keyup', this.onKeyUp); + + this.props.onClose(); + } + + private onModClicked(selected: any) { + const updatedFriends = this.state.modList.map(member => { + if (member.id === selected.id) { + return { ...member, checkmarked: !member.checkmarked }; + } else { + return member; + } + }); + + this.setState(state => { + return { + ...state, + modList: updatedFriends, + }; + }); + } +}