[deploy] 🚀 Feature: Chat channel membership manager component (#8945)

* Feature 🚀 : Ability to delete messages in chat channels

- Sending message ID to frontend
- Deleting Message
- Use pusher to delete message realtime

* Minor Bug 🐞: Show message action only for current user

- User can delete or edit their own messages

* Test cases added

* Bug 🐞: Update message id for receiver

Message id was not sent to receiver by pusher

* Refactoring🛠: Message controller refactoring

* Test Cases📝 : Specs for Delete message added

* Feature 🚀 : Ability to edit messages

* Test Cases📝 : Specs for Edit message added

* Merge conflict resolved

* fix video content issue

* add UI for membership management

* add api to update memberahip role

* add methods for manage membership

* Add integration test cases

* add emoji for admin

* Open member profile in sidecar only

* 🐞 Problem with direct channel sidecar

* 🐞 Few other UI enhancements

* fix mod typo

* add limit upto 4 for display active memberships

* fix UI issues

* fix action ui for membership

* fix sidebar redirection issue

* fix svg buttons

* add test cases

* fixed broken spec

* fix PR suggestions

* remove not used code

* fix typo

* fix PR suggestion

* fix typos

* add invitation url expiry

* fix specs

* fix PR suggestions

* removed unused gem

* remove presenter format

* handle invitation link expiry with redis

* fix PR suggestions

* user can view non-discoverable channel invitation link

* fix typos

* remove commented code

* add spacing

* PR suggestions

* remove class from button

* replace componentDidMount with commen function

* remove action button for single membership

* add chat message on update role

* add spece between lines

Co-authored-by: Narender Singh <narender2031@gmail.com>
Co-authored-by: Fernando Valverde <fdov88@gmail.com>
This commit is contained in:
Sarthak Sharma 2020-07-20 17:38:31 +05:30 committed by GitHub
parent 803d482f90
commit 3addb64326
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 1333 additions and 430 deletions

View file

@ -2,4 +2,5 @@
## Reporting a Vulnerability
Please refer to https://dev.to/security if you would like to report a vulnerability.
Please refer to https://dev.to/security if you would like to report a
vulnerability.

View file

@ -282,6 +282,7 @@
@include themeable(border, theme-border, 1px solid $outline-color);
padding: 13px;
min-width: 50%;
overflow-x: hidden;
@media screen and (min-width: 1440px) {
min-width: 45%;
}
@ -1164,3 +1165,66 @@
.channel_details {
margin-top: 30px;
}
.chat_channel-member-list {
height: 300px;
overflow-y: scroll;
}
.member-list-item {
justify-content: space-between;
.admin-emoji {
width: 6%;
}
.admin-emoji-button {
width: 10%;
}
}
.membership-section {
position: relative;
.membership-actions {
position: relative;
.membership-management__dropdown-memu {
display: none;
position: absolute;
background-color: var(--theme-container-background, #fff);
border-style: solid;
border-color: var(--theme-color, #0a0a0a);
border-radius: 5px;
border-width: 1px;
z-index: 100;
width: 200px;
right: 78px;
top: 20px;
button {
width: 100%;
text-align: left;
}
&:hover {
display: block;
}
}
.membership-management__dropdown-button {
height: 17px;
cursor: pointer;
color: var(--theme-color, #0a0a0a);
&:hover + .membership-management__dropdown-memu {
display: block;
}
img {
height: 100%;
opacity: 0.6;
}
}
}
}

View file

@ -25,15 +25,17 @@ class ChatChannelMembershipsController < ApplicationController
@membership = ChatChannelMembership.find(params[:id])
authorize @membership
@channel = @membership.chat_channel
data = ChatChannelDetailPresenter.new(@channel, @membership).as_json
render json: { success: true, result: data, message: "" }, success: :ok
invite_cache_key = "chat-channel-invite-#{@channel.id}"
invitation_slug = Rails.cache.fetch(invite_cache_key, expires_in: 12.hours) do
"invitation-link-#{SecureRandom.hex(3)}"
end
@invitation_link = "/join_channel_invitation/#{@channel.slug}?invitation_slug=#{invitation_slug}"
end
def create_membership_request
chat_channel = ChatChannel.find_by(id: channel_membership_request_params[:chat_channel_id])
chat_channel = ChatChannel.find_by(id: channel_membership_params[:chat_channel_id])
authorize chat_channel, :update?
usernames = channel_membership_request_params[:invitation_usernames].split(",").map do |username|
usernames = channel_membership_params[:invitation_usernames].split(",").map do |username|
username.strip.delete("@")
end
users = User.where(username: usernames)
@ -131,14 +133,82 @@ class ChatChannelMembershipsController < ApplicationController
end
end
def update_membership_role
@chat_channel = ChatChannel.find_by(id: params[:id])
authorize @chat_channel, :update?
membership = ChatChannelMembership.find_by(
id: channel_membership_params[:membership_id],
chat_channel_id: @chat_channel.id,
)
membership.update(role: channel_membership_params[:role])
if membership.errors.any?
render json: {
success: false,
message: "Failed to update membership",
errors: chat_channel_membership.errors.full_messages
}, status: :bad_request
else
role = membership.reload.role
send_chat_action_message(
"@#{membership.user.username} role is updated as #{role}",
current_user, @chat_channel.id,
"updated"
)
render json: { success: true, message: "User Membership is updated" }, status: :ok
end
end
def join_channel_invitation
@chat_channel = ChatChannel.find_by(slug: params[:channel_slug])
authorize @chat_channel
invite_cache_key = "chat-channel-invite-#{@chat_channel.id}"
invitation_slug = Rails.cache.read(invite_cache_key)
existing_membership = ChatChannelMembership.find_by(user_id: current_user.id, chat_channel_id: @chat_channel.id)
redirect_to connect_path(@chat_channel.slug) if existing_membership && existing_membership.status == "active"
@link_expired = true if invitation_slug != params[:invitation_slug]
end
def joining_invitation_response
chat_channel = ChatChannel.find_by(id: params[:chat_channel_id])
authorize chat_channel
if params[:user_action] == "accept"
membership = ChatChannelMembership.find_by(user_id: current_user.id, chat_channel_id: chat_channel.id)
if !membership
membership = ChatChannelMembership.new(user_id: current_user.id, chat_channel_id: chat_channel.id)
membership.save
unless membership&.errors&.any?
send_chat_action_message("@#{membership.user.username} join the channel", current_user, chat_channel.id,
"joined")
end
elsif membership.status != "active"
# This check checks if the user already has the chatChannelMembership with the status pending, joining_request
# Then update it to as active.
membership.update(role: "member", status: "active")
send_chat_action_message("@#{membership.user.username} join the channel", current_user,
membership.chat_channel_id, "joined")
end
if membership&.errors&.any?
flash[:settings_notice] = membership.errors.full_messages
redirect_to root_path
end
redirect_to connect_path(chat_channel.slug)
else
redirect_to root_path
end
end
private
def permitted_params
params.require(:chat_channel_membership).permit(:user_action, :show_global_badge_notification)
end
def channel_membership_request_params
params.require(:chat_channel_membership).permit(:chat_channel_id, :invitation_usernames)
def channel_membership_params
params.require(:chat_channel_membership).permit(:chat_channel_id, :invitation_usernames, :membership_id, :role)
end
def respond_to_invitation(previous_status)
@ -176,9 +246,9 @@ class ChatChannelMembershipsController < ApplicationController
notice = "Invitation rejected."
end
flash[:settings_notice] = notice
membership_user = helpers.format_membership(@chat_channel_membership)
membership_user = MembershipUserPresenter.new(@chat_channel_membership).as_json
flash[:settings_notice] = notice
respond_to do |format|
format.html { redirect_to chat_channel_memberships_path }

View file

@ -0,0 +1,27 @@
module ChatChannelMembershipHelper
def membership_users(memberships)
memberships.includes(:user).map do |membership|
{
name: membership.user.name,
username: membership.user.username,
user_id: membership.user.id,
membership_id: membership.id,
role: membership.role,
status: membership.status,
image: ProfileImage.new(membership.user).get(width: 90)
}
end
end
def format_membership(membership)
{
name: membership.user.name,
username: membership.user.username,
user_id: membership.user.id,
membership_id: membership.id,
role: membership.role,
status: membership.status,
image: ProfileImage.new(membership.user).get(width: 90)
}
end
end

View file

@ -1,12 +1,18 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
import { defaulMembershipPropType } from '../../common-prop-types/membership-prop-type';
import Membership from './Membership';
import { Button } from '@crayons';
const ActiveMembershipSection = ({
activeMemberships,
removeMembership,
currentMembershipRole,
toggleScreens,
}) => {
const activeMembershipList = activeMemberships.slice(0, 4);
return (
<div
data-testid="active-memberships"
@ -14,35 +20,34 @@ const ActiveMembershipSection = ({
data-active-count={activeMemberships ? activeMemberships.length : 0}
>
<h3 className="mb-2 active_members">Members</h3>
{activeMemberships && activeMemberships.length > 0
? activeMemberships.map((pendingMembership) => (
{activeMembershipList.map((activeMembership) => (
<Membership
membership={pendingMembership}
membership={activeMembership}
removeMembership={removeMembership}
membershipType="active"
currentMembershipRole={currentMembershipRole}
className="active-member"
/>
))
: null}
))}
<div className="row align-center">
<Button
className="align-center view-all-memberships"
size="s"
onClick={toggleScreens}
type="button"
>
View All
</Button>
</div>
</div>
);
};
ActiveMembershipSection.propTypes = {
activeMemberships: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string.isRequired,
membership_id: PropTypes.number.isRequired,
user_id: PropTypes.number.isRequired,
role: PropTypes.string.isRequired,
image: PropTypes.string.isRequired,
username: PropTypes.string.isRequired,
status: PropTypes.string.isRequired,
}),
).isRequired,
activeMemberships: PropTypes.arrayOf(defaulMembershipPropType).isRequired,
removeMembership: PropTypes.func.isRequired,
currentMembershipRole: PropTypes.string.isRequired,
toggleScreens: PropTypes.func.isRequired,
};
export default ActiveMembershipSection;

View file

@ -1,8 +1,9 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
import { defaulMembershipPropType } from '../../common-prop-types/membership-prop-type';
import ActiveMembershipSection from './ActiveMembershipsSection';
import PendingMembershipSection from './PendingMembershipSection'
import PendingMembershipSection from './PendingMembershipSection';
import RequestedMembershipSection from './RequestedMembershipSection';
const ChatChannelMembershipSection = ({
@ -11,7 +12,8 @@ const ChatChannelMembershipSection = ({
chatChannelAcceptMembership,
activeMemberships,
removeMembership,
currentMembershipRole
currentMembershipRole,
toggleScreens,
}) => {
return (
<div className="membership-list">
@ -19,6 +21,7 @@ const ChatChannelMembershipSection = ({
activeMemberships={activeMemberships}
removeMembership={removeMembership}
currentMembershipRole={currentMembershipRole}
toggleScreens={toggleScreens}
/>
<PendingMembershipSection
pendingMemberships={pendingMemberships}
@ -32,38 +35,17 @@ const ChatChannelMembershipSection = ({
currentMembershipRole={currentMembershipRole}
/>
</div>
)
}
);
};
ChatChannelMembershipSection.propTypes = {
pendingMemberships: PropTypes.arrayOf(PropTypes.shape({
name: PropTypes.string.isRequired,
membership_id: PropTypes.number.isRequired,
user_id: PropTypes.number.isRequired,
role: PropTypes.string.isRequired,
image: PropTypes.string.isRequired,
username: PropTypes.string.isRequired,
})).isRequired,
requestedMemberships: PropTypes.arrayOf(PropTypes.shape({
name: PropTypes.string.isRequired,
membership_id: PropTypes.number.isRequired,
user_id: PropTypes.number.isRequired,
role: PropTypes.string.isRequired,
image: PropTypes.string.isRequired,
username: PropTypes.string.isRequired,
})).isRequired,
activeMemberships: PropTypes.arrayOf(PropTypes.shape({
name: PropTypes.string.isRequired,
membership_id: PropTypes.number.isRequired,
user_id: PropTypes.number.isRequired,
role: PropTypes.string.isRequired,
image: PropTypes.string.isRequired,
username: PropTypes.string.isRequired,
status: PropTypes.string.isRequired,
})).isRequired,
pendingMemberships: PropTypes.arrayOf(defaulMembershipPropType).isRequired,
requestedMemberships: PropTypes.arrayOf(defaulMembershipPropType).isRequired,
activeMemberships: PropTypes.arrayOf(defaulMembershipPropType).isRequired,
removeMembership: PropTypes.func.isRequired,
chatChannelAcceptMembership: PropTypes.func.isRequired,
currentMembershipRole: PropTypes.string.isRequired
}
currentMembershipRole: PropTypes.string.isRequired,
toggleScreens: PropTypes.func.isRequired,
};
export default ChatChannelMembershipSection;

View file

@ -9,15 +9,12 @@ import {
updateChatChannelDescription,
sendChatChannelInvitation,
leaveChatChannelMembership,
updateMembershipRole,
} from '../actions/chat_channel_setting_actions';
import { addSnackbarItem } from '../../Snackbar';
import ModSection from './ModSection';
import PersonalSettings from './PersonalSetting';
import LeaveMembershipSection from './LeaveMembershipSection';
import ModFaqSection from './ModFaqSection';
import ChannelDescriptionSection from './ChannelDescriptionSection';
import ChatChannelMembershipSection from './ChatChannelMembershipSection';
import ManageActiveMembership from './MembershipManager/ManageActiveMembership';
import ChatChannelSettingsSection from './ChatChannelSettingsSection';
export default class ChatChannelSettings extends Component {
static propTypes = {
@ -35,16 +32,30 @@ export default class ChatChannelSettings extends Component {
requestedMemberships: [],
chatChannel: null,
currentMembership: null,
activeMembershipId: props.activeMembershipId,
activeMembershipId: null,
channelDescription: null,
channelDiscoverable: null,
invitationUsernames: null,
showGlobalBadgeNotification: null,
displaySettings: true,
displayMembershipManager: false,
invitationLink: null,
};
}
componentDidMount() {
const { activeMembershipId } = this.state;
this.updateChannelDetails();
}
componentWillReceiveProps() {
const { activeMembershipId } = this.props;
this.setState({
activeMembershipId,
});
}
updateChannelDetails = () => {
const { activeMembershipId } = this.props;
getChannelDetails(activeMembershipId)
.then((response) => {
@ -60,6 +71,7 @@ export default class ChatChannelSettings extends Component {
channelDiscoverable: result.chat_channel.discoverable,
showGlobalBadgeNotification:
result.current_membership.show_global_badge_notification,
invitationLink: result.invitation_link,
});
} else {
this.setState({
@ -74,7 +86,7 @@ export default class ChatChannelSettings extends Component {
errorMessages: error.message,
});
});
}
};
handleDescriptionChange = (e) => {
const description = e.target.value;
@ -83,7 +95,7 @@ export default class ChatChannelSettings extends Component {
});
};
handlePersonChatChennelSetting = (e) => {
handlePersonChannelSetting = (e) => {
const status = e.target.checked;
this.setState({
showGlobalBadgeNotification: status,
@ -149,6 +161,7 @@ export default class ChatChannelSettings extends Component {
updateMemberships = (membershipId, response, membershipStatus) => {
if (response.success) {
this.updateChannelDetails();
this.setState((prevState) => {
return {
errorMessages: null,
@ -236,7 +249,7 @@ export default class ChatChannelSettings extends Component {
const { message } = response;
if (response.success) {
this.componentDidMount();
this.updateChannelDetails();
this.setState((prevState) => {
return {
errorMessages: null,
@ -265,13 +278,13 @@ export default class ChatChannelSettings extends Component {
});
};
handleChatChannelInvitations = async () => {
handleChannelInvitations = async () => {
const { invitationUsernames, chatChannel } = this.state;
const { id } = chatChannel;
const response = await sendChatChannelInvitation(id, invitationUsernames);
const { message } = response;
if (response.success) {
this.componentDidMount();
this.updateChannelDetails();
this.setState({
errorMessages: null,
successMessages: response.message,
@ -286,7 +299,7 @@ export default class ChatChannelSettings extends Component {
addSnackbarItem({ message });
};
handleleaveChatChannelMembership = async () => {
handleleaveChannelMembership = async () => {
// eslint-disable-next-line no-restricted-globals
const actionStatus = confirm(
'Are you absolutely sure you want to leave this channel? This action is permanent.',
@ -295,7 +308,7 @@ export default class ChatChannelSettings extends Component {
if (actionStatus) {
const response = await leaveChatChannelMembership(currentMembership.id);
if (response.success) {
this.componentDidMount();
this.updateChannelDetails();
} else {
this.setState({
successMessages: null,
@ -305,6 +318,53 @@ export default class ChatChannelSettings extends Component {
}
};
toggleScreens = () => {
const { displaySettings, displayMembershipManager } = this.state;
this.setState({
displaySettings: !displaySettings,
displayMembershipManager: !displayMembershipManager,
});
};
handleUpdateMembershipRole = async (e) => {
const { membershipId, role } = e.target.dataset;
const { chatChannel } = this.state;
const response = await updateMembershipRole(
membershipId,
chatChannel.id,
role,
);
const { message } = response;
if (response.success) {
this.updateChannelDetails();
this.setState((prevState) => {
const { activeMemberships } = prevState;
const updatedActiveMemberships = activeMemberships.map(
(activeMembership) => {
if (activeMembership.membership_id === Number(membershipId)) {
return { ...activeMembership, role };
}
return activeMembership;
},
);
return {
...prevState,
activeMemberships: updatedActiveMemberships,
errorMessages: null,
successMessages: response.message,
};
});
} else {
this.setState({
successMessages: null,
errorMessages: response.message,
});
}
addSnackbarItem({ message });
};
render() {
const {
chatChannel,
@ -316,6 +376,8 @@ export default class ChatChannelSettings extends Component {
channelDiscoverable,
invitationUsernames,
showGlobalBadgeNotification,
displaySettings,
invitationLink,
} = this.state;
if (!chatChannel) {
@ -325,53 +387,44 @@ export default class ChatChannelSettings extends Component {
return (
<div className="activechatchannel__activeArticle channel_settings">
<div className="p-4">
<ChannelDescriptionSection
channelName={chatChannel.name}
channelDescription={chatChannel.description}
currentMembershipRole={currentMembership.role}
/>
<ChatChannelMembershipSection
currentMembershipRole={currentMembership.role}
activeMemberships={activeMemberships}
removeMembership={this.removeMembership}
pendingMemberships={pendingMemberships}
requestedMemberships={requestedMemberships}
chatChannelAcceptMembership={this.chatChannelAcceptMembership}
/>
{currentMembership.role === 'mod' && (
<ModSection
invitationUsernames={invitationUsernames}
handleInvitationUsernames={this.handleInvitationUsernames}
handleChatChannelInvitations={this.handleChatChannelInvitations}
channelDescription={channelDescription}
handleDescriptionChange={this.handleDescriptionChange}
{displaySettings ? (
<ChatChannelSettingsSection
channelDiscoverable={channelDiscoverable}
handleChannelDiscoverableStatus={
this.handleChannelDiscoverableStatus
}
handleChannelDescriptionChanges={
this.handleChannelDescriptionChanges
}
currentMembershipRole={currentMembership.role}
/>
)}
<PersonalSettings
updateCurrentMembershipNotificationSettings={
this.updateCurrentMembershipNotificationSettings
}
showGlobalBadgeNotification={showGlobalBadgeNotification}
handlePersonChatChennelSetting={this.handlePersonChatChennelSetting}
/>
{currentMembership.role === 'member' && (
<LeaveMembershipSection
currentMembershipRole={currentMembership.role}
handleleaveChatChannelMembership={
this.handleleaveChatChannelMembership
handleleaveChannelMembership={this.handleleaveChannelMembership}
handlePersonChannelSetting={this.handlePersonChannelSetting}
handleChannelDescriptionChanges={
this.handleChannelDescriptionChanges
}
handleChannelDiscoverableStatus={
this.handleChannelDiscoverableStatus
}
handleDescriptionChange={this.handleDescriptionChange}
handleChannelInvitations={this.handleChannelInvitations}
handleInvitationUsernames={this.handleInvitationUsernames}
toggleScreens={this.toggleScreens}
removeMembership={this.removeMembership}
chatChannelAcceptMembership={this.chatChannelAcceptMembership}
channelDescription={channelDescription}
chatChannel={chatChannel}
currentMembership={currentMembership}
activeMemberships={activeMemberships}
pendingMemberships={pendingMemberships}
requestedMemberships={requestedMemberships}
invitationUsernames={invitationUsernames}
showGlobalBadgeNotification={showGlobalBadgeNotification}
/>
) : (
<ManageActiveMembership
activeMemberships={activeMemberships}
currentMembership={currentMembership}
chatChannel={chatChannel}
invitationLink={invitationLink}
removeMembership={this.removeMembership}
handleUpdateMembershipRole={this.handleUpdateMembershipRole}
/>
)}
{currentMembership.role === 'mod' && (
<ModFaqSection email="yo@dev.to" />
)}
</div>
</div>

View file

@ -0,0 +1,106 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
import ModSection from './ModSection';
import PersonalSettings from './PersonalSetting';
import LeaveMembershipSection from './LeaveMembershipSection';
import ModFaqSection from './ModFaqSection';
import ChannelDescriptionSection from './ChannelDescriptionSection';
import ChatChannelMembershipSection from './ChatChannelMembershipSection';
const ChatChannelSettingsSection = ({
channelDiscoverable,
updateCurrentMembershipNotificationSettings,
handleleaveChannelMembership,
handlePersonChannelSetting,
handleChannelDescriptionChanges,
handleChannelDiscoverableStatus,
handleDescriptionChange,
handleChannelInvitations,
handleInvitationUsernames,
toggleScreens,
removeMembership,
chatChannelAcceptMembership,
channelDescription,
chatChannel,
currentMembership,
activeMemberships,
pendingMemberships,
requestedMemberships,
invitationUsernames,
showGlobalBadgeNotification,
}) => (
<div>
<ChannelDescriptionSection
channelName={chatChannel.name}
channelDescription={chatChannel.description}
currentMembershipRole={currentMembership.role}
className="channel-description-section"
/>
<ChatChannelMembershipSection
currentMembershipRole={currentMembership.role}
activeMemberships={activeMemberships}
removeMembership={removeMembership}
pendingMemberships={pendingMemberships}
requestedMemberships={requestedMemberships}
chatChannelAcceptMembership={chatChannelAcceptMembership}
toggleScreens={toggleScreens}
className="channel-membership-sections"
/>
<ModSection
invitationUsernames={invitationUsernames}
handleInvitationUsernames={handleInvitationUsernames}
handleChannelInvitations={handleChannelInvitations}
channelDescription={channelDescription}
handleDescriptionChange={handleDescriptionChange}
channelDiscoverable={channelDiscoverable}
handleChannelDiscoverableStatus={handleChannelDiscoverableStatus}
handleChannelDescriptionChanges={handleChannelDescriptionChanges}
currentMembershipRole={currentMembership.role}
className="channel-mod-section"
/>
<PersonalSettings
updateCurrentMembershipNotificationSettings={
updateCurrentMembershipNotificationSettings
}
showGlobalBadgeNotification={showGlobalBadgeNotification}
handlePersonChannelSetting={handlePersonChannelSetting}
className="channel-personal-seeting"
/>
<LeaveMembershipSection
currentMembershipRole={currentMembership.role}
handleleaveChannelMembership={handleleaveChannelMembership}
className="channel-leave-membership-section"
/>
<ModFaqSection
currentMembershipRole={currentMembership.role}
email="yo@dev.to"
className="channel-mod-faq"
/>
</div>
);
ChatChannelSettingsSection.propTypes = {
chatChannel: PropTypes.isRequired,
currentMembership: PropTypes.isRequired,
activeMemberships: PropTypes.isRequired,
pendingMemberships: PropTypes.isRequired,
requestedMemberships: PropTypes.isRequired,
invitationUsernames: PropTypes.string.isRequired,
channelDescription: PropTypes.string.isRequired,
channelDiscoverable: PropTypes.bool.isRequired,
showGlobalBadgeNotification: PropTypes.bool.isRequired,
handleleaveChannelMembership: PropTypes.func.isRequired,
chatChannelAcceptMembership: PropTypes.func.isRequired,
removeMembership: PropTypes.func.isRequired,
toggleScreens: PropTypes.func.isRequired,
handleInvitationUsernames: PropTypes.func.isRequired,
handleChannelInvitations: PropTypes.func.isRequired,
handleDescriptionChange: PropTypes.func.isRequired,
handleChannelDiscoverableStatus: PropTypes.func.isRequired,
handleChannelDescriptionChanges: PropTypes.func.isRequired,
handlePersonChannelSetting: PropTypes.func.isRequired,
updateCurrentMembershipNotificationSettings: PropTypes.func.isRequired,
};
export default ChatChannelSettingsSection;

View file

@ -1,8 +1,9 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
import { Button } from '@crayons';
const InviteForm = ({
handleChatChannelInvitations,
handleChannelInvitations,
invitationUsernames,
handleInvitationUsernames,
}) => {
@ -26,13 +27,9 @@ const InviteForm = ({
/>
</div>
<div>
<button
className="crayons-btn"
type="submit"
onClick={handleChatChannelInvitations}
>
<Button type="submit" onClick={handleChannelInvitations}>
Submit
</button>
</Button>
</div>
</div>
);
@ -40,7 +37,7 @@ const InviteForm = ({
InviteForm.propTypes = {
handleInvitationUsernames: PropTypes.func.isRequired,
handleChatChannelInvitations: PropTypes.func.isRequired,
handleChannelInvitations: PropTypes.func.isRequired,
invitationUsernames: PropTypes.func.isRequired,
};

View file

@ -1,25 +1,34 @@
import { h } from 'preact';
import PropsType from 'prop-types';
import { Button } from '@crayons';
const LeaveMembershipSection = ({
handleleaveChannelMembership,
currentMembershipRole,
}) => {
if (currentMembershipRole === 'mod') {
return null;
}
const LeaveMembershipSection = ({ handleleaveChatChannelMembership }) => {
return (
<div className="crayons-card p-4 grid gap-2 mb-4 leave_membership_section">
<h3>Danger Zone</h3>
<div>
<button
className="crayons-btn crayons-btn--danger leave_button"
<Button
className="leave_button"
variant="danger"
type="submit"
onClick={handleleaveChatChannelMembership}
onClick={handleleaveChannelMembership}
>
Leave Channel
</button>
</Button>
</div>
</div>
);
};
LeaveMembershipSection.propTypes = {
handleleaveChatChannelMembership: PropsType.func.isRequired,
handleleaveChannelMembership: PropsType.func.isRequired,
};
export default LeaveMembershipSection;

View file

@ -1,5 +1,7 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
import { defaulMembershipPropType } from '../../common-prop-types/membership-prop-type';
import { Button } from '@crayons';
const Membership = ({
membership,
@ -10,7 +12,13 @@ const Membership = ({
}) => {
return (
<div className="flex items-center">
<a href={`/${membership.username}`} title={`${membership.name} profile`}>
<a
href={`/${membership.username}`}
className="chatmessagebody__username--link"
target="_blank"
rel="noopener noreferrer"
data-content="sidecar-user"
>
<span className="crayons-avatar crayons-avatar--l mr-3">
<img
className="crayons-avatar__image align-middle"
@ -21,17 +29,17 @@ const Membership = ({
<span className="mr-2 user_name">{membership.name}</span>
</a>
{membershipType === 'requested' ? (
<button
<Button
className="crayons-btn crayons-btn--icon-rounded crayons-btn--ghost add-membership"
type="button"
onClick={chatChannelAcceptMembership}
data-membership-id={membership.membership_id}
>
+
</button>
</Button>
) : null}
{membership.role !== 'mod' && currentMembershipRole === 'mod' ? (
<button
<Button
className="crayons-btn crayons-btn--icon-rounded crayons-btn--ghost remove-membership"
type="button"
onClick={removeMembership}
@ -39,24 +47,14 @@ const Membership = ({
data-membership-status={membership.status}
>
x
</button>
</Button>
) : null}
</div>
);
};
Membership.propTypes = {
membership: PropTypes.objectOf(
PropTypes.shape({
name: PropTypes.string.isRequired,
membership_id: PropTypes.number.isRequired,
user_id: PropTypes.number.isRequired,
role: PropTypes.string.isRequired,
image: PropTypes.string.isRequired,
username: PropTypes.string.isRequired,
status: PropTypes.string.isRequired,
}),
).isRequired,
membership: PropTypes.objectOf(defaulMembershipPropType).isRequired,
removeMembership: PropTypes.func.isRequired,
membershipType: PropTypes.func.isRequired,
chatChannelAcceptMembership: PropTypes.func.isRequired,

View file

@ -0,0 +1,121 @@
import { h, Component } from 'preact';
import PropTypes from 'prop-types';
import { isNativeAndroid } from '../../../utilities/validateAndroidNative';
import { Button } from '@crayons';
function isClipboardSupported() {
return (
typeof navigator.clipboard !== 'undefined' && navigator.clipboard !== null
);
}
const CopyIcon = () => (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
className="crayons-icon copy-icon"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-labelledby="fc5f15add1e114844f5e"
>
<title id="fc5f15add1e114844f5e">Copy Invitation Url</title>
<path d="M7 6V3a1 1 0 011-1h12a1 1 0 011 1v14a1 1 0 01-1 1h-3v3c0 .552-.45 1-1.007 1H4.007A1 1 0 013 21l.003-14c0-.552.45-1 1.007-1H7zm2 0h8v10h2V4H9v2zm-2 5v2h6v-2H7zm0 4v2h6v-2H7z" />
</svg>
);
export default class InvitationLinkManager extends Component {
static propTypes = {
invitationLink: PropTypes.string.isRequired,
currentMembership: PropTypes.isRequired,
};
constructor(props) {
super(props);
this.state = {
invitationLink: props.invitationLink,
showImageCopiedMessage: false,
currentMembership: props.currentMembership,
};
}
copyText = () => {
this.imageMarkdownInput = document.getElementById(
'chat-channel-unviation-url',
);
if (isNativeAndroid()) {
AndroidBridge.copyToClipboard(this.imageMarkdownInput.value);
this.setState({ showImageCopiedMessage: true });
} else if (isClipboardSupported()) {
navigator.clipboard
.writeText(this.imageMarkdownInput.value)
.then(() => {
this.setState({ showImageCopiedMessage: true });
})
.catch((_err) => {
this.execCopyText();
});
} else {
this.execCopyText();
}
};
execCopyText() {
this.imageMarkdownInput.setSelectionRange(
0,
this.imageMarkdownInput.value.length,
);
document.execCommand('copy');
this.setState({ showImageCopiedMessage: true });
}
render() {
const {
showImageCopiedMessage,
invitationLink,
currentMembership,
} = this.state;
if (currentMembership.role !== 'mod') {
return null;
}
return (
<div className="p-4 grid gap-2 crayons-card my-4 invitation-section">
<h3 className="text-center title">Invitation Link</h3>
<clipboard-copy
onClick={this.copyText}
for="chat-channel-unviation-url"
aria-live="polite"
className="flex items-center flex-1"
aria-controls="image-markdown-copy-link-announcer"
>
<input
type="text"
className="crayons-textfield mr-2"
id="chat-channel-unviation-url"
readOnly="true"
value={invitationLink}
aria-label="invitation-link"
/>
<Button
className="spec__image-markdown-copy"
variant="ghost"
contentType="icon"
icon={CopyIcon}
/>
<span
id="image-markdown-copy-link-announcer"
role="alert"
className={`fs-s ${showImageCopiedMessage ? '' : 'opacity-0'}`}
>
Copied!
</span>
</clipboard-copy>
</div>
);
}
}

View file

@ -0,0 +1,119 @@
import { h, Component } from 'preact';
import PropTypes from 'prop-types';
import MembershipSection from './MembershipSection';
import InvitationLinkManager from './InvitationLinkManager';
export default class ManageActiveMembership extends Component {
static propTypes = {
activeMemberships: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string.isRequired,
membership_id: PropTypes.number.isRequired,
user_id: PropTypes.number.isRequired,
role: PropTypes.string.isRequired,
image: PropTypes.string.isRequired,
username: PropTypes.string.isRequired,
status: PropTypes.string.isRequired,
}),
).isRequired,
currentMembership: PropTypes.isRequired,
invitationLink: PropTypes.string.isRequired,
removeMembership: PropTypes.func.isRequired,
handleUpdateMembershipRole: PropTypes.func.isRequired,
};
constructor(props) {
super(props);
this.state = {
activeMemberships: props.activeMemberships,
searchMembers: null,
listAllMemberShips: props.activeMemberships,
currentMembership: props.currentMembership,
invitationLink: props.invitationLink,
removeMembership: props.removeMembership,
handleUpdateMembershipRole: props.handleUpdateMembershipRole,
};
}
componentWillReceiveProps() {
const {
activeMemberships,
currentMembership,
removeMembership,
invitationLink,
} = this.props;
this.setState({
listAllMemberShips: activeMemberships,
currentMembership,
invitationLink,
removeMembership,
activeMemberships,
});
}
searchTheMembershipUser = (e) => {
const query = e.target?.value?.toLowerCase();
this.setState((prevState) => {
const filteredActiveMemberships = prevState.activeMemberships.filter(
(activeMembership) => {
const value = activeMembership.name.toLowerCase();
return value.includes(query);
},
);
return {
searchMembers: query,
listAllMemberShips: filteredActiveMemberships,
};
});
};
render() {
const {
searchMembers,
listAllMemberShips,
currentMembership,
invitationLink,
removeMembership,
handleUpdateMembershipRole,
} = this.state;
return (
<div className="pt-3">
<div className="p-4 grid gap-2 crayons-card my-4 mx-auto membership-manager">
<h2 className="text-title">Chat Channel Membership manager</h2>
</div>
<div className="p-4 grid gap-2 crayons-card my-4">
<input
type="text"
className="crayons-textfield"
placeholder="Search Member..."
value={searchMembers}
name="search-members"
onKeyUp={this.searchTheMembershipUser.bind(this)}
aria-label="search memberships"
/>
<div
data-spy="scroll"
data-offset="5"
className="chat_channel-member-list"
>
<MembershipSection
memberships={listAllMemberShips}
currentMembership={currentMembership}
removeMembership={removeMembership}
handleUpdateMembershipRole={handleUpdateMembershipRole}
/>
</div>
</div>
<InvitationLinkManager
invitationLink={invitationLink}
currentMembership={currentMembership}
className="inviation-link-section"
/>
</div>
);
}
}

View file

@ -0,0 +1,130 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
import adminEmoji from '../../../../assets/images/emoji/apple-fire.png';
import { Button } from '@crayons';
const Membership = ({
membership,
currentMembership,
removeMembership,
handleUpdateMembershipRole,
showActionButton,
}) => {
const addAsModButton =
membership.role === 'member' ? (
<Button
type="button"
size="s"
onClick={handleUpdateMembershipRole}
data-membership-id={membership.membership_id}
data-role="mod"
>
Promote to Mod
</Button>
) : null;
const addAsMemberButton =
membership.role === 'mod' ? (
<Button
type="button"
size="s"
onClick={handleUpdateMembershipRole}
data-membership-id={membership.membership_id}
data-role="member"
>
Remove Mod
</Button>
) : null;
const removeMembershipButton =
membership.role === 'member' ? (
<Button
type="button"
size="s"
variant="ghost-danger"
onClick={removeMembership}
data-membership-id={membership.membership_id}
data-membership-status={membership.status}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 32.526 32.526"
width="18"
height="18"
className="crayons-icon"
data-membership-id={membership.membership_id}
data-membership-status={membership.status}
>
<path
fill="#4f5458"
data-membership-id={membership.membership_id}
data-membership-status={membership.status}
d="M32.526 2.828L29.698 0 16.263 13.435 2.828 0 0 2.828l13.435 13.435L0 29.698l2.828 2.828 13.435-13.435 13.435 13.435 2.828-2.828-13.435-13.435z"
/>
</svg>
</Button>
) : null;
const dropdown =
currentMembership.role === 'mod' && showActionButton ? (
<span className="membership-actions">
{removeMembershipButton}
{addAsModButton}
{addAsMemberButton}
</span>
) : null;
return (
<div className="flex items-center my-3 member-list-item justify-content-between">
<div className="">
<a
href={`/${membership.username}`}
className="chatmessagebody__username--link"
target="_blank"
rel="noopener noreferrer"
data-content="sidecar-user"
>
<span className="crayons-avatar crayons-avatar--l mr-3">
<img
className="crayons-avatar__image align-middle"
src={membership.image}
alt={`${membership.name} profile`}
/>
</span>
<span className="mr-2 user_name">{membership.name}</span>
<span>
{membership.role === 'mod' ? (
<img
src={adminEmoji}
alt="admin emoji"
data-content="admin emoji"
className="admin-emoji"
title="MOD"
/>
) : null}
</span>
</a>
</div>
<div className="">{dropdown}</div>
</div>
);
};
Membership.propTypes = {
membership: PropTypes.objectOf(
PropTypes.shape({
name: PropTypes.string.isRequired,
membership_id: PropTypes.number.isRequired,
user_id: PropTypes.number.isRequired,
role: PropTypes.string.isRequired,
image: PropTypes.string.isRequired,
username: PropTypes.string.isRequired,
status: PropTypes.string.isRequired,
}),
).isRequired,
currentMembership: PropTypes.isRequired,
removeMembership: PropTypes.func.isRequired,
handleUpdateMembershipRole: PropTypes.func.isRequired,
};
export default Membership;

View file

@ -0,0 +1,52 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
import Membership from './Membership';
const MembershipSection = ({
memberships,
currentMembership,
removeMembership,
handleUpdateMembershipRole,
}) => {
if (!memberships || memberships.length === 0) {
return <p className="lh-base">No membership</p>;
}
const membershipCount = memberships.length;
return (
<div className="membership-section">
{memberships.map((activeMembership) => (
<Membership
membership={activeMembership}
membershipType="active"
currentMembershipRole={() => {}}
className="active-member"
currentMembership={currentMembership}
removeMembership={removeMembership}
handleUpdateMembershipRole={handleUpdateMembershipRole}
showActionButton={membershipCount > 1}
/>
))}
</div>
);
};
MembershipSection.propType = {
memberships: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string.isRequired,
membership_id: PropTypes.number.isRequired,
user_id: PropTypes.number.isRequired,
role: PropTypes.string.isRequired,
image: PropTypes.string.isRequired,
username: PropTypes.string.isRequired,
status: PropTypes.string.isRequired,
}),
).isRequired,
currentMembership: PropTypes.isRequired,
removeMembership: PropTypes.func.isRequired,
handleUpdateMembershipRole: PropTypes.func.isRequired,
};
export default MembershipSection;

View file

@ -1,7 +1,11 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
const ModFaqSection = ({ email }) => {
const ModFaqSection = ({ email, currentMembershipRole }) => {
if (currentMembershipRole === 'member') {
return null;
}
return (
<div className="crayons-card grid gap-2 p-4 faq-section">
<p className="contact-details">
@ -21,6 +25,7 @@ const ModFaqSection = ({ email }) => {
ModFaqSection.propTypes = {
email: PropTypes.string.isRequired,
currentMembershipRole: PropTypes.string.isRequired,
};
export default ModFaqSection;

View file

@ -5,7 +5,7 @@ import InviteForm from './InviteForm';
import SettingsForm from './SettingsForm';
const ModSection = ({
handleChatChannelInvitations,
handleChannelInvitations,
invitationUsernames,
handleInvitationUsernames,
channelDescription,
@ -13,13 +13,18 @@ const ModSection = ({
channelDiscoverable,
handleChannelDiscoverableStatus,
handleChannelDescriptionChanges,
currentMembershipRole,
}) => {
if (currentMembershipRole === 'member') {
return null;
}
return (
<div className="mod-section">
<InviteForm
handleInvitationUsernames={handleInvitationUsernames}
invitationUsernames={invitationUsernames}
handleChatChannelInvitations={handleChatChannelInvitations}
handleChannelInvitations={handleChannelInvitations}
/>
<SettingsForm
channelDescription={channelDescription}
@ -34,7 +39,7 @@ const ModSection = ({
ModSection.propTypes = {
handleInvitationUsernames: PropTypes.func.isRequired,
handleChatChannelInvitations: PropTypes.func.isRequired,
handleChannelInvitations: PropTypes.func.isRequired,
invitationUsernames: PropTypes.func.isRequired,
channelDescription: PropTypes.string.isRequired,
handleDescriptionChange: PropTypes.func.isRequired,

View file

@ -1,5 +1,7 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
import { defaulMembershipPropType } from '../../common-prop-types/membership-prop-type';
import Membership from './Membership';
const PendingMembershipSection = ({
@ -34,17 +36,7 @@ const PendingMembershipSection = ({
};
PendingMembershipSection.propTypes = {
pendingMemberships: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string.isRequired,
membership_id: PropTypes.number.isRequired,
user_id: PropTypes.number.isRequired,
role: PropTypes.string.isRequired,
image: PropTypes.string.isRequired,
username: PropTypes.string.isRequired,
status: PropTypes.string.isRequired,
}),
).isRequired,
pendingMemberships: PropTypes.arrayOf(defaulMembershipPropType).isRequired,
removeMembership: PropTypes.func.isRequired,
currentMembershipRole: PropTypes.func.isRequired,
};

View file

@ -1,8 +1,9 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
import { Button } from '@crayons';
const PersonalSettings = ({
handlePersonChatChennelSetting,
handlePersonChannelSetting,
showGlobalBadgeNotification,
updateCurrentMembershipNotificationSettings,
}) => {
@ -16,20 +17,19 @@ const PersonalSettings = ({
id="c3"
className="crayons-checkbox"
checked={showGlobalBadgeNotification}
onChange={handlePersonChatChennelSetting}
onChange={handlePersonChannelSetting}
/>
<label htmlFor="c3" className="crayons-field__label">
Receive Notifications for New Messages
</label>
</div>
<div>
<button
className="crayons-btn"
<Button
type="submit"
onClick={updateCurrentMembershipNotificationSettings}
>
Submit
</button>
</Button>
</div>
</div>
);
@ -38,7 +38,7 @@ const PersonalSettings = ({
PersonalSettings.propTypes = {
updateCurrentMembershipNotificationSettings: PropTypes.func.isRequired,
showGlobalBadgeNotification: PropTypes.bool.isRequired,
handlePersonChatChennelSetting: PropTypes.func.isRequired,
handlePersonChannelSetting: PropTypes.func.isRequired,
};
export default PersonalSettings;

View file

@ -1,5 +1,7 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
import { defaulMembershipPropType } from '../../common-prop-types/membership-prop-type';
import Membership from './Membership';
const RequestedMembershipSection = ({
@ -8,7 +10,7 @@ const RequestedMembershipSection = ({
chatChannelAcceptMembership,
currentMembershipRole,
}) => {
if (currentMembershipRole !== 'mod') {
if (currentMembershipRole === 'member') {
return null;
}
@ -37,17 +39,7 @@ const RequestedMembershipSection = ({
};
RequestedMembershipSection.propTypes = {
requestedMemberships: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string.isRequired,
membership_id: PropTypes.number.isRequired,
user_id: PropTypes.number.isRequired,
role: PropTypes.string.isRequired,
image: PropTypes.string.isRequired,
username: PropTypes.string.isRequired,
status: PropTypes.string.isRequired,
}),
).isRequired,
requestedMemberships: PropTypes.arrayOf(defaulMembershipPropType).isRequired,
removeMembership: PropTypes.func.isRequired,
chatChannelAcceptMembership: PropTypes.func.isRequired,
currentMembershipRole: PropTypes.func.isRequired,

View file

@ -1,5 +1,6 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
import { Button } from '@crayons';
const SettingsFrom = ({
channelDescription,
@ -42,13 +43,9 @@ const SettingsFrom = ({
</label>
</div>
<div>
<button
className="crayons-btn"
type="submit"
onClick={handleChannelDescriptionChanges}
>
<Button type="submit" onClick={handleChannelDescriptionChanges}>
Submit
</button>
</Button>
</div>
</div>
);

View file

@ -0,0 +1,41 @@
import { h } from 'preact';
import { render } from '@testing-library/preact';
import { axe } from 'jest-axe';
import ManageActiveMembership from '../ChatChannelSettings/MembershipManager/ManageActiveMembership';
const currentModMembership = {
name: 'dummy user',
username: 'dummyuser',
user_id: 1,
chat_channel_id: 2,
status: 'active',
role: 'mod',
};
describe('<ManageActiveMembership />', () => {
it('should have no a11y violations', async () => {
const { container } = render(
<ManageActiveMembership
invitationLink={'https://dummy-invitation.link'}
currentMembership={currentModMembership}
activeMemberships={[]}
/>,
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should render', () => {
const { queryByText, queryByPlaceholderText } = render(
<ManageActiveMembership
invitationLink={'https://dummy-invitation.link'}
currentMembership={currentModMembership}
activeMemberships={[]}
/>,
);
expect(queryByText('Chat Channel Membership manager')).toBeDefined();
expect(queryByPlaceholderText('Search Member...')).toBeDefined();
});
});

View file

@ -0,0 +1,46 @@
import { h } from 'preact';
import { render } from '@testing-library/preact';
import { axe } from 'jest-axe';
import Membership from '../ChatChannelSettings/MembershipManager/Membership';
const membershipData = {
name: 'dummy Name',
user_id: 1,
chat_channel_id: 2,
membership_id: 1,
username: 'dummyuser',
};
const currentModMembership = {
name: 'dummy user',
username: 'dummyuser',
user_id: 1,
chat_channel_id: 2,
status: 'active',
role: 'mod',
};
describe('<Membership />', () => {
it('should have no a11y violations', async () => {
const { container } = render(
<Membership
currentMembership={currentModMembership}
membership={membershipData}
/>,
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should render', () => {
const { queryByText } = render(
<Membership
currentMembership={currentModMembership}
membership={membershipData}
/>,
);
expect(queryByText('dummy user')).toBeDefined();
});
});

View file

@ -7,6 +7,7 @@ import {
updateChatChannelDescription,
sendChatChannelInvitation,
leaveChatChannelMembership,
updateMembershipRole,
} from '../actions/chat_channel_setting_actions';
/* global globalThis */
@ -200,4 +201,26 @@ describe('Chat cahnnel API requestes', () => {
expect(result).toEqual(response);
});
});
describe('Update the membership role', () => {
it('should have the success response', async () => {
const response = { success: true, message: 'user membership is updated' };
fetch.mockResponse(JSON.stringify(response));
const result = await updateMembershipRole(
chanChannelMembershipId,
channelId,
'mod',
);
expect(result).toEqual(response);
});
it('should return the not found', async () => {
const response = { success: false, message: 'not found' };
fetch.mockResponse(JSON.stringify(response));
const result = await updateMembershipRole('', '', 'mod');
expect(result).toEqual(response);
});
});
});

View file

@ -0,0 +1,74 @@
import { h } from 'preact';
import { render } from '@testing-library/preact';
import { axe } from 'jest-axe';
import InviationLinkManager from '../ChatChannelSettings/MembershipManager/InvitationLinkManager';
const currentModMembership = {
name: 'dummy user',
username: 'dummyuser',
user_id: 1,
chat_channel_id: 2,
status: 'active',
role: 'mod',
};
const currentMemberMembership = {
name: 'dummy member',
username: 'dummymember',
user_id: 1,
chat_channel_id: 2,
status: 'active',
role: 'member',
};
const svg = (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
className="crayons-icon copy-icon"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-labelledby="fc5f15add1e114844f5e"
>
<title id="fc5f15add1e114844f5e">Copy Invitation Url</title>
<path d="M7 6V3a1 1 0 011-1h12a1 1 0 011 1v14a1 1 0 01-1 1h-3v3c0 .552-.45 1-1.007 1H4.007A1 1 0 013 21l.003-14c0-.552.45-1 1.007-1H7zm2 0h8v10h2V4H9v2zm-2 5v2h6v-2H7zm0 4v2h6v-2H7z" />
</svg>
);
describe('<InviationLinkManager />', () => {
it('should have no a11y violations', async () => {
const { container } = render(
<InviationLinkManager
invitationLink={'https://dummy-invitation.link'}
currentMembership={currentModMembership}
/>,
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should render', () => {
const { queryByText } = render(
<InviationLinkManager
invitationLink={'https://dummy-invitation.link'}
currentMembership={currentModMembership}
/>,
);
expect(queryByText('https://dummy-invitation.link')).toBeDefined();
expect(queryByText('Invitation Link')).toBeDefined();
});
it('should not render', () => {
const { rerender } = render(
<InviationLinkManager
invitationLink={'https://dummy-invitation.link'}
currentMembership={currentMemberMembership}
/>,
);
expect(rerender()).toEqual(undefined);
});
});

View file

@ -21,9 +21,7 @@ describe('<LeaveMembershipSection />', () => {
it('should have user leave channel when leave button is clicked', () => {
const leaveHandler = jest.fn();
const { getByText } = render(
<LeaveMembershipSection
handleleaveChatChannelMembership={leaveHandler}
/>,
<LeaveMembershipSection handleleaveChannelMembership={leaveHandler} />,
);
const leaveButton = getByText('Leave Channel');

View file

@ -1,167 +0,0 @@
import { h } from 'preact';
import { render } from '@testing-library/preact';
import { axe } from 'jest-axe';
import Membership from '../ChatChannelSettings/Membership';
function getModUser() {
return {
membership: {
name: 'test user',
username: 'testusername',
user_id: '1',
membership_id: '2',
role: 'mod',
status: 'active',
image: '',
},
membershipType: 'active',
currentMembershipRole: 'mod',
};
}
function getMemberUser() {
return {
membership: {
name: 'test user',
username: 'testusername',
user_id: '1',
membership_id: '2',
role: 'member',
status: 'active',
image: '',
},
membershipType: 'requested',
currentMembershipRole: 'mod',
};
}
describe('<Membership />', () => {
it('should have no a11y violations for a moderator user', async () => {
const { membership, membershipType, currentMembershipRole } = getModUser();
const { container } = render(
<Membership
membership={membership}
membershipType={membershipType}
currentMembershipRole={currentMembershipRole}
/>,
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should have no a11y violations for a member user', async () => {
const {
membership,
membershipType,
currentMembershipRole,
} = getMemberUser();
const { container } = render(
<Membership
membership={membership}
membershipType={membershipType}
currentMembershipRole={currentMembershipRole}
/>,
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should render a moderator user', () => {
const { membership, membershipType, currentMembershipRole } = getModUser();
const { getByAltText, getByTitle } = render(
<Membership
membership={membership}
membershipType={membershipType}
currentMembershipRole={currentMembershipRole}
/>,
);
const userProfileLink = getByTitle('test user profile');
expect(userProfileLink.getAttribute('href')).toEqual('/testusername');
getByAltText('test user profile');
});
it('should render a member user', () => {
const {
membership,
membershipType,
currentMembershipRole,
} = getMemberUser();
const { getByAltText, getByTitle, getByText } = render(
<Membership
membership={membership}
membershipType={membershipType}
currentMembershipRole={currentMembershipRole}
/>,
);
// users requesting to join channel
const userProfileLink = getByTitle('test user profile');
expect(userProfileLink.getAttribute('href')).toEqual('/testusername');
getByAltText('test user profile');
const addButton = getByText('+', {
selector: 'button',
});
expect(addButton.dataset.membershipId).toEqual('2');
const deleteButton = getByText('x', { selector: 'button' });
expect(deleteButton.dataset.membershipId).toEqual('2');
expect(deleteButton.dataset.membershipStatus).toEqual('active');
});
it('should not show add/remove buttons for a moderator', () => {
const { membership, membershipType, currentMembershipRole } = getModUser();
const { queryByText } = render(
<Membership
membership={membership}
membershipType={membershipType}
currentMembershipRole={currentMembershipRole}
/>,
);
// a moderator should not have add or remove buttons
expect(
queryByText('+', {
selector: 'button',
}),
).toBeNull();
expect(
queryByText('x', {
selector: 'button',
}),
).toBeNull();
});
it('should show add/remove buttons for a member', () => {
const {
membership,
membershipType,
currentMembershipRole,
} = getMemberUser();
const { queryByText } = render(
<Membership
membership={membership}
membershipType={membershipType}
currentMembershipRole={currentMembershipRole}
/>,
);
expect(
queryByText('+', {
selector: 'button',
}),
).toBeDefined();
expect(
queryByText('x', {
selector: 'button',
}),
).toBeDefined();
});
});

View file

@ -142,7 +142,31 @@ export async function leaveChatChannelMembership(membershipId) {
`/chat_channel_memberships/leave_membership/${membershipId}`,
{
method: 'PATCH',
credentials: 'same-origin',
},
);
return response.json();
}
/**
* This function is used to update the membership role
* @param {number} membershipId selected User Chat channel membership id
* @param {number} chatChannelId Current chat chaneel id
* @param {string} role updated role for the membership
*/
export async function updateMembershipRole(membershipId, chatChannelId, role) {
const response = await request(
`/chat_channel_memberships/update_membership_role/${chatChannelId}`,
{
method: 'PATCH',
body: {
chat_channel_membership: {
chat_channel_id: chatChannelId,
membership_id: membershipId,
role,
},
},
credentials: 'same-origin',
},
);

View file

@ -1796,6 +1796,11 @@ export default class Chat extends Component {
? 'sidecar-user'
: `chat_channel_setting`;
const contentLink =
activeChannel.channel_type === 'direct'
? `/${activeChannel.channel_username}`
: '#/';
return (
<a
className="crayons-btn crayons-btn--icon-rounded crayons-btn--ghost"
@ -1804,7 +1809,7 @@ export default class Chat extends Component {
if (e.keyCode === 13) this.triggerActiveContent(e);
}}
tabIndex="0"
href={path}
href={contentLink}
data-content={dataContent}
>
<svg
@ -1813,8 +1818,12 @@ export default class Chat extends Component {
width="24"
height="24"
className="crayons-icon"
data-content={dataContent}
>
<path d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm0-2a8 8 0 1 0 0-16 8 8 0 0 0 0 16zM11 7h2v2h-2V7zm0 4h2v6h-2v-6z" />
<path
data-content={dataContent}
d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm0-2a8 8 0 1 0 0-16 8 8 0 0 0 0 16zM11 7h2v2h-2V7zm0 4h2v6h-2v-6z"
/>
</svg>
</a>
);

View file

@ -40,13 +40,13 @@ export default class Content extends Component {
return (
// TODO: A button (role="button") cannot contain other interactive elements, i.e. buttons.
// TODO: These should have key click events as well.
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
<div
className="activechatchannel__activecontent activechatchannel__activecontent--sidecar"
id="chat_activecontent"
onClick={onTriggerContent}
role="button"
tabIndex="0"
aria-hidden="true"
>
<button
type="button"

View file

@ -0,0 +1,11 @@
import PropTypes from 'prop-types';
export const defaulMembershipPropType = PropTypes.shape({
name: PropTypes.string.isRequired,
membership_id: PropTypes.number.isRequired,
user_id: PropTypes.number.isRequired,
role: PropTypes.string.isRequired,
image: PropTypes.string.isRequired,
username: PropTypes.string.isRequired,
status: PropTypes.string.isRequired,
});

View file

@ -0,0 +1,10 @@
/**
* This function is used to check that is Device is android native or not
*/
export function isNativeAndroid() {
return (
navigator.userAgent === 'DEV-Native-android' &&
typeof AndroidBridge !== 'undefined' &&
AndroidBridge !== null
);
}

View file

@ -39,6 +39,18 @@ class ChatChannelPolicy < ApplicationPolicy
user_can_edit_channel
end
def join_channel_invitation?
record.present? && user.id
end
def set_channel?
user_can_edit_channel
end
def joining_invitation_response?
record.present?
end
private
def user_can_edit_channel

View file

@ -1,42 +0,0 @@
class ChatChannelDetailPresenter
def initialize(chat_channel, current_membership)
@chat_channel = chat_channel
@current_membership = current_membership
end
attr_accessor :chat_channel, :current_membership
def as_json
{
chat_channel: {
name: chat_channel.channel_name,
type: chat_channel.channel_type,
description: chat_channel.description,
discoverable: chat_channel.discoverable,
slug: chat_channel.slug,
status: chat_channel.status,
id: chat_channel.id
},
memberships: {
active: membership_users(chat_channel.active_memberships),
pending: membership_users(chat_channel.pending_memberships),
requested: membership_users(chat_channel.requested_memberships)
},
current_membership: current_membership
}
end
def membership_users(memberships)
memberships.includes(:user).map do |membership|
{
name: membership.user.name,
username: membership.user.username,
user_id: membership.user.id,
membership_id: membership.id,
role: membership.role,
status: membership.status,
image: ProfileImage.new(membership.user).get(width: 90)
}
end
end
end

View file

@ -1,19 +0,0 @@
class MembershipUserPresenter
def initialize(membership)
@membership = membership
end
attr_accessor :membership
def as_json
{
name: membership.user.name,
username: membership.user.username,
user_id: membership.user.id,
membership_id: membership.id,
role: membership.role,
status: membership.status,
image: ProfileImage.new(membership.user).get(width: 90)
}
end
end

View file

@ -0,0 +1,20 @@
json.success true
json.result do
json.chat_channel do
json.name @channel.channel_name
json.type @channel.channel_type
json.description @channel.description
json.discoverable @channel.discoverable
json.slug @channel.slug
json.status @channel.status
json.id @channel.id
end
json.memberships do
json.active membership_users(@channel.active_memberships)
json.pending membership_users(@channel.pending_memberships)
json.requested membership_users(@channel.requested_memberships)
end
json.current_membership @membership
json.invitation_link URL.url(@invitation_link)
end

View file

@ -0,0 +1,17 @@
<div class="grid gap-6 p-6 justify-center">
<% if @link_expired %>
<h1>This Invitation link is expired. Please contact the channel mod for new Invitation link</h1>
<% else %>
<h1 class="mt-5">Would you like to join the <%= @chat_channel.channel_name %> channel</h1>
<%= form_with(url: joining_invitation_response_url, html: { class: "align-center" }) do |f| %>
<%= f.hidden_field :user_action, value: "accept" %>
<%= f.hidden_field :chat_channel_id, value: @chat_channel.id %>
<%= f.submit "Accept", class: "crayons-btn crayons-btn--s" %>
<% end %>
<%= form_with(url: joining_invitation_response_url, html: { class: "align-center" }) do |f| %>
<%= f.hidden_field :user_action, value: "decline" %>
<%= f.hidden_field :chat_channel_id, value: @chat_channel.id %>
<%= f.submit "Decline", class: "crayons-btn crayons-btn--danger mb-5 crayons-btn--s" %>
<% end %>
<% end %>
</div>

View file

@ -2,6 +2,6 @@ import querystring;
sub vcl_recv {
# return this URL with only the parameters that match this regular expression
if (req.url !~ "/internal/" && req.url !~ "/search/" && req.url !~ "/bulk_show") {
set req.url = querystring.regfilter_except(req.url, "^(a_id|args|article_id|article_ids|articles|asc|callback_url|category|chat_channel_id|client_id|code|collection_id|commentable_id|commentable_type|confirmation_token|created_at|end|filter|followable_id|followable_type|fork_id|i|key|message_offset|name|oauth_token|oauth_verifier|offset|org_id|organization_id|p|page|per_page|prefill|preview|purchaser|reactable_ids|redirect_uri|reported_url|reporter_username|response_type|scope|search|signature|sort|start|state|status|tag|tag_list|top|type_of|url|username|invitation_token|reset_password_token|ut|verb)$");
set req.url = querystring.regfilter_except(req.url, "^(a_id|args|article_id|article_ids|articles|asc|callback_url|category|chat_channel_id|client_id|code|collection_id|commentable_id|commentable_type|confirmation_token|created_at|end|filter|followable_id|followable_type|fork_id|i|key|message_offset|name|oauth_token|oauth_verifier|offset|org_id|organization_id|p|page|per_page|prefill|preview|purchaser|reactable_ids|redirect_uri|reported_url|reporter_username|response_type|scope|search|signature|sort|start|state|status|tag|tag_list|top|type_of|url|username|invitation_token|reset_password_token|ut|verb|invitation_slug)$");
}
}

View file

@ -302,6 +302,9 @@ Rails.application.routes.draw do
post "/chat_channel_memberships/create_membership_request" => "chat_channel_memberships#create_membership_request"
patch "/chat_channel_memberships/leave_membership/:id" => "chat_channel_memberships#leave_membership"
patch "/chat_channel_memberships/update_membership/:id" => "chat_channel_memberships#update_membership"
patch "/chat_channel_memberships/update_membership_role/:id" => "chat_channel_memberships#update_membership_role"
get "/join_channel_invitation/:channel_slug" => "chat_channel_memberships#join_channel_invitation"
post "/joining_invitation_response" => "chat_channel_memberships#joining_invitation_response"
get "/social_previews/article/:id" => "social_previews#article", :as => :article_social_preview
get "/social_previews/user/:id" => "social_previews#user", :as => :user_social_preview

View file

@ -16,12 +16,13 @@ RSpec.describe "ChatChannelMemberships", type: :request do
user.add_role(:super_admin)
membership = ChatChannelMembership.find_by(chat_channel_id: chat_channel.id, user_id: user.id)
get "/chat_channel_memberships/chat_channel_info/#{membership.id}"
get "/chat_channel_memberships/chat_channel_info/#{membership.id}", as: :json
end
it "return all details of chat channel" do
expect(response).to have_http_status(:ok)
expect(response.parsed_body["result"].keys).to eq(%w[chat_channel memberships current_membership])
expect(response.status).to eq(200)
expect(response.parsed_body["result"].keys).to eq(%w[chat_channel memberships current_membership
invitation_link])
end
end
@ -31,14 +32,15 @@ RSpec.describe "ChatChannelMemberships", type: :request do
chat_channel.add_users([second_user])
membership = ChatChannelMembership.find_by(chat_channel_id: chat_channel.id, user_id: second_user.id)
get "/chat_channel_memberships/chat_channel_info/#{membership.id}"
get "/chat_channel_memberships/chat_channel_info/#{membership.id}", as: :json
end
it "return only channel info and current membership" do
expect(response.status).to eq(200)
expect(JSON.parse(response.body)["result"].keys).to eq(%w[chat_channel memberships current_membership])
expect(JSON.parse(response.body)["result"]["memberships"]["pending"].length).to eq(0)
expect(JSON.parse(response.body)["result"]["memberships"]["requested"].length).to eq(0)
expect(response.parsed_body["result"].keys).to eq(%w[chat_channel memberships current_membership
invitation_link])
expect(response.parsed_body["result"]["memberships"]["pending"].length).to eq(0)
expect(response.parsed_body["result"]["memberships"]["requested"].length).to eq(0)
end
end
end
@ -338,4 +340,120 @@ RSpec.describe "ChatChannelMemberships", type: :request do
end
end
end
describe "PATCH /update_membership_role" do
before do
user.add_role(:super_admin)
chat_channel.add_users([second_user])
end
context "when user role is member" do
it "update the membership role to mod" do
allow(Pusher).to receive(:trigger).and_return(true)
membership = ChatChannelMembership.find_by(chat_channel_id: chat_channel.id, user_id: second_user.id)
patch "/chat_channel_memberships/update_membership_role/#{chat_channel.id}", params: {
chat_channel_membership: {
membership_id: membership.id,
role: "mod"
}
}
expect(response.status).to eq(200)
expect(membership.reload.role).to eq("mod")
end
end
context "when user is mod" do
it "update the membership role to member" do
allow(Pusher).to receive(:trigger).and_return(true)
membership = ChatChannelMembership.find_by(chat_channel_id: chat_channel.id, user_id: user.id)
patch "/chat_channel_memberships/update_membership_role/#{chat_channel.id}", params: {
chat_channel_membership: {
membership_id: membership.id,
role: "member"
}
}
expect(response.status).to eq(200)
expect(membership.reload.role).to eq("member")
end
end
context "when there is no channel id" do
it "channel not found" do
membership = ChatChannelMembership.find_by(chat_channel_id: chat_channel.id, user_id: second_user.id)
patch "/chat_channel_memberships/update_membership_role/", params: {
chat_channel_membership: {
membership_id: membership.id,
role: "member"
}
}
expect(response.status).to eq(404)
expect(membership.reload.role).to eq("member")
end
end
end
describe "GET /join_channel_invitation" do
context "when user is not member" do
it "render the page" do
allow(Pusher).to receive(:trigger).and_return(true)
sign_in second_user
chat_channel.update(discoverable: true)
get "/join_channel_invitation/#{chat_channel.slug}"
expect(response.status).to eq(200)
end
end
context "when user is not logged-in" do
it "not allowed to create membership" do
sign_out second_user
get "/join_channel_invitation/#{chat_channel.slug}"
expect(response.status).to eq(401)
end
end
end
describe "POST /joining_invitation_response" do
context "when user accept the request" do
it "will create membership" do
allow(Pusher).to receive(:trigger).and_return(true)
sign_in second_user
chat_channel.update(discoverable: true)
post "/joining_invitation_response", params: {
user_action: "accept",
chat_channel_id: chat_channel.id
}
membership = ChatChannelMembership.last
expect(response.status).to eq(302)
expect(membership.user_id).to eq(second_user.id)
end
end
context "when user decline the request" do
it "will not create the membership" do
sign_in second_user
chat_channel.update(discoverable: true)
post "/joining_invitation_response", params: {
user_action: "decline",
chat_channel_id: chat_channel.id
}
membership = ChatChannelMembership.last
expect(response.status).to eq(302)
expect(membership.user_id).not_to eq(second_user.id)
end
end
end
end