[deploy] Connect: move settings to sidecar iframe, improve invitations and remove experimental features (#6903)

* 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

* Move channel settings to sidecar

* added spec for chat_channels_memberships_controller.rb (#6539)

* added spec for chat_channels_memberships_controller.rb

* simplified record not found error for membership

* error message formatting updated

* added error key in flash message

Co-authored-by: jitendra <jitendrarajpurohit@skynox.tech>

* Refining "Move sidecar to iframe" branch (#6634)

* added spec for chat_channels_memberships_controller.rb

* simplified record not found error for membership

* error message formatting updated

* added error key in flash message

* 🐞 Bug Fix: Open sidecar of clicking @all

* 🐞 Bug Fix: adding space after selecting the username from suggestion box

* added remove channel member functionality for moderators

* 🔩 Small Tweaks: Hover issue fixed

* 🐞 Bug Fix: Remove sidecar toggle on @all tag (for now)

* updated manually constructed urls with rails path helpers

* mod users cannot remove members of other channel

Co-authored-by: jitendra <jitendrarajpurohit@skynox.tech>

* 🚀 Finalising the connect group/channel feature.  (#6829)

* added spec for chat_channels_memberships_controller.rb

* simplified record not found error for membership

* error message formatting updated

* added error key in flash message

* 🐞 Bug Fix: Open sidecar of clicking @all

* 🐞 Bug Fix: adding space after selecting the username from suggestion box

* added remove channel member functionality for moderators

* 🔩 Small Tweaks: Hover issue fixed

* 🐞 Bug Fix: Remove sidecar toggle on @all tag (for now)

* updated manually constructed urls with rails path helpers

* mod users cannot remove members of other channel

* mod members can now invite user who have left the channel

* 🐞 Bug Fix: Ability to open sidecar on mention all properly

* Eslint Changes

* send email notification on inviting user feature added

* 🛠  Email design enhancements for channel invitation

* send invitation to mod user added from internal

* 🛠  Gif changes for Mod

* spec fixed

* inviter name issue fixed

* 🚀 Open sidecar when automatically if you use link from email

Co-authored-by: jitendra <jitendrarajpurohit@skynox.tech>

* review changes

* added spec for channel_invite_email

* Bug🐞: Design problem with sidecar after merge

* Bug🐞: Design problem with invitation alert box

* replaced instance variables with local variable

* Bug🐞: Wrong css classes fixed

* Bug🐞: Edit option bug

* yarn.lock file updated

* code review changes

* updated manually constructed urls with rails path helpers in spec

* user name url issue fix in invite email

* user url corrected

Co-authored-by: Sarthak Sharma <7lovesharma7@gmail.com>
Co-authored-by: benhalpern <bendhalpern@gmail.com>
This commit is contained in:
jituraj98 2020-04-01 21:43:26 +05:30 committed by GitHub
parent 05b6220e2d
commit e2edf960dc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
44 changed files with 797 additions and 2046 deletions

View file

@ -1,5 +1,4 @@
@import 'variables';
@import 'connect/_channel-details';
@import 'mixins';
// High level classes
@ -645,54 +644,19 @@
}
}
.activecontent__githubrepo {
}
.activecontent__githubrepoheader {
padding-left: 30px;
padding-bottom: 3px;
min-height: 30px;
margin-top: -9px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 0.9em;
}
.activecontent__githubrepofiles {
border-bottom: 1px solid $light-medium-gray;
font-size: 0.8em;
overflow: auto;
&.activecontent__githubrepofiles--root {
height: 280px;
max-height: 40vh;
}
}
.activecontent__githubreporeadme {
font-size: 0.8em;
img {
max-width: 100%;
}
}
.activecontent__githubrepofilerow {
padding: 4px 2px;
border-top: 1px solid $light-medium-gray;
}
.chat__channelinvitationsindicator button {
background: $green;
.chat__channelinvitationsindicator a {
background: linear-gradient(10deg, darken($green, 25%), darken($green, 15%));
text-align: center;
padding: 10px 0px;
padding: 30px 0px;
display: block;
border-radius: 3px;
margin: 3px 0px;
border: 0px;
width: 93%;
font-size: 0.9em;
width: 94%;
color: white;
font-size: 1.1em;
font-weight: 700;
-webkit-appearance: unset;
}
.chatchannels {
@ -1255,6 +1219,9 @@
border-left: 3px solid #4e57ef;
height: 50px;
overflow: hidden;
p {
margin: 18px 0;
}
}
.closeEdit {

View file

@ -104,6 +104,18 @@ pre {
}
}
.cta {
color: $black;
background: $green;
font-family: $helvetica-condensed;
-webkit-appearance: none;
font-stretch: condensed;
font-weight: bold;
&.cta-danger {
background: $red;
}
}
button {
cursor: pointer;
}

View file

@ -4,6 +4,8 @@
$input-width: 650px;
#page-content.users-edit,
#page-content.chat_channel_memberships-edit,
#page-content.chat_channel_memberships-index,
#page-content.users-update,
#page-content.users-confirm_destroy,
#page-content.organizations-create,
@ -12,18 +14,25 @@ $input-width: 650px;
#notice {
background: $green;
color: black;
padding: 40px 0px 40px;
padding: 40px 15px 40px;
text-align: center;
position: relative;
top: 0;
left: 0;
right: 0;
font-weight: 700;
font-size: 1.05em;
&.error-notice {
background: rgba(255, 80, 80, 0.7);
color: white;
}
}
.user-settings-page {
h1,
h3,
p {
margin: 10px 0 !important;
}
width: 900px;
max-width: 94%;
margin: 0 auto;
@ -340,6 +349,27 @@ $input-width: 650px;
resize: none;
}
}
%inline-button-form {
display: inline-block;
input[type='submit'] {
display: inline-block;
font-size: 17px;
border: 0px;
border-radius: 100px;
padding: 2px 10px;
width: auto;
margin: 0px;
margin-left: 5px;
color: white;
background: $black;
}
}
.inline-button-form {
@extend %inline-button-form;
}
.inline-button-form-hidden {
display: none;
}
h2 {
font-size: calc(40px + 1vw);
font-family: $helvetica-condensed;
@ -676,6 +706,31 @@ $input-width: 650px;
}
}
.settings-form-userlist {
.settings-form-userlistrow {
font-size: 18px;
padding: 6px 0px;
min-height: 33px;
display: block;
@include themeable(color, theme-color, $black);
a {
@include themeable(color, theme-color, $black);
}
img {
height: 1.5em;
width: 1.5em;
border-radius: 100px;
vertical-align: -0.25em;
margin-right: 0.25em;
}
&:hover {
.inline-button-form-hidden {
@extend %inline-button-form;
}
}
}
}
.signout_confirm-wrapper {
padding: calc(15% + 50px) 2% 30%;
text-align: center;
@ -786,3 +841,14 @@ ul.delete__account {
.membership-purchase-link {
font-size: 2rem;
}
#page-content.chat_channel_memberships-edit,
#page-content.chat_channel_memberships-index {
#page-content-inner {
margin-top: 40px;
}
.notice {
margin-top: -40px;
margin-bottom: 20px;
}
}

View file

@ -1,6 +1,11 @@
class ChatChannelMembershipsController < ApplicationController
after_action :verify_authorized
def index
skip_authorization
@pending_invites = current_user.chat_channel_memberships.includes(:chat_channel).where(status: "pending")
end
def find_by_chat_channel_id
@membership = ChatChannelMembership.where(chat_channel_id: params[:chat_channel_id], user_id: current_user.id).first!
authorize @membership
@ -11,41 +16,80 @@ class ChatChannelMembershipsController < ApplicationController
)
end
def edit
@membership = ChatChannelMembership.find(params[:id])
@channel = @membership.chat_channel
authorize @membership
end
def create
@chat_channel = ChatChannel.find(permitted_params[:chat_channel_id])
membership_params = params[:chat_channel_membership]
@chat_channel = ChatChannel.find(membership_params[:chat_channel_id])
authorize @chat_channel, :update?
ChatChannelMembership.create(
user_id: permitted_params[:user_id],
chat_channel_id: @chat_channel.id,
status: "pending",
)
usernames = membership_params[:invitation_usernames].split(",").map { |username| username.strip.delete("@") }
users = User.where(username: usernames)
invitations_sent = @chat_channel.invite_users(users: users, membership_role: "member", inviter: current_user)
flash[:settings_notice] = if invitations_sent.zero?
"No invitations sent. Check for username typos."
else
"#{invitations_sent} #{'invitation'.pluralize(invitations_sent)} sent."
end
membership = @chat_channel.chat_channel_memberships.find_by!(user: current_user)
redirect_to edit_chat_channel_membership_path(membership)
end
def remove_membership
@chat_channel = ChatChannel.find(params[:chat_channel_id])
authorize @chat_channel, :update?
@chat_channel_membership = @chat_channel.chat_channel_memberships.find(params[:membership_id])
if params[:status] == "pending"
@chat_channel_membership.destroy
flash[:settings_notice] = "Invitation removed."
else
@chat_channel_membership.update(status: "removed_from_channel")
flash[:settings_notice] = "Removed #{@chat_channel_membership.user.name}"
end
membership = ChatChannelMembership.find_by!(chat_channel_id: params[:chat_channel_id], user: current_user)
redirect_to edit_chat_channel_membership_path(membership)
end
def update
@chat_channel_membership = ChatChannelMembership.find(params[:id])
authorize @chat_channel_membership
if permitted_params[:user_action] == "accept"
@chat_channel_membership.update(status: "active")
if permitted_params[:user_action].present?
respond_to_invitation
else
@chat_channel_membership.update(status: "rejected")
@chat_channel_membership.update(permitted_params)
flash[:settings_notice] = "Personal settings updated."
redirect_to edit_chat_channel_membership_path(@chat_channel_membership.id)
end
@chat_channels_memberships = current_user.
chat_channel_memberships.includes(:chat_channel).
where(status: "pending").
order("chat_channel_memberships.updated_at DESC")
render "chat_channels/index.json"
end
def destroy
@chat_channel_membership = ChatChannel.find(params[:id]).
chat_channel_memberships.where(user_id: current_user.id).first
@chat_channel_membership = ChatChannelMembership.find(params[:id])
authorize @chat_channel_membership
channel_name = @chat_channel_membership.chat_channel.channel_name
@chat_channel_membership.update(status: "left_channel")
@chat_channels_memberships = []
render json: { result: "left channel" }, status: :created
flash[:settings_notice] = "You have left the channel #{channel_name}. It may take a moment to be removed from your list."
redirect_to chat_channel_memberships_path
end
private
def permitted_params
params.require(:chat_channel_membership).permit(:user_id, :chat_channel_id, :user_action, :id)
params.require(:chat_channel_membership).permit(:user_action, :show_global_badge_notification)
end
def respond_to_invitation
if permitted_params[:user_action] == "accept"
@chat_channel_membership.update(status: "active")
channel_name = @chat_channel_membership.chat_channel.channel_name
flash[:settings_notice] = "Invitation to #{channel_name} accepted. It may take a moment to show up in your list."
else
@chat_channel_membership.update(status: "rejected")
flash[:settings_notice] = "Invitation rejected."
end
redirect_to chat_channel_memberships_path
end
end

View file

@ -30,8 +30,14 @@ class ChatChannelsController < ApplicationController
end
def update
ChatChannelUpdateService.new(@chat_channel, chat_channel_params).update
render_chat_channel
if ChatChannelUpdateService.new(@chat_channel, chat_channel_params).update
flash[:settings_notice] = "Channel settings updated."
else
default_error_message = "Channel settings updation failed. Try again later."
flash[:error] = @chat_channel.errors.full_messages.to_sentence.presence || default_error_message
end
current_user_membership = @chat_channel.mod_memberships.find_by!(user: current_user)
redirect_to edit_chat_channel_membership_path(current_user_membership)
end
def open
@ -75,7 +81,7 @@ class ChatChannelsController < ApplicationController
valid_listing = ClassifiedListing.where(user_id: params[:user_id], contact_via_connect: true).limit(1)
authorize ChatChannel
if chat_recipient.inbox_type == "open" || valid_listing.length == 1
chat = ChatChannel.create_with_users([current_user, chat_recipient], "direct")
chat = ChatChannel.create_with_users(users: [current_user, chat_recipient], channel_type: "direct")
message_markdown = params[:message]
message = Message.new(
chat_channel: chat,

View file

@ -6,13 +6,18 @@ class Internal::ChatChannelsController < Internal::ApplicationController
end
def create
ChatChannel.create_with_users(users_by_param, "invite_only", chat_channel_params[:channel_name])
ChatChannel.create_with_users(
users: users_by_param,
channel_type: "invite_only",
contrived_name: chat_channel_params[:channel_name],
membership_role: "mod",
)
redirect_back(fallback_location: "/internal/chat_channels")
end
def update
@chat_channel = ChatChannel.find(params[:id])
@chat_channel.add_users(users_by_param)
@chat_channel.invite_users(users: users_by_param, membership_role: "mod")
redirect_back(fallback_location: "/internal/chat_channels")
end

View file

@ -1,14 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<CodeEditor /> should render and test snapshot 1`] = `
<div
class="chatcodeeditor"
id="codeeditor"
>
<div
class="chatcodeeditor__header"
>
Experimental (WIP)
</div>
</div>
`;

View file

@ -1,100 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<GithubRepo /> with fake token should render and test snapshot 1`] = `
preact-render-spy (1 nodes)
-------
<div class="activecontent__githubrepo">
<div class="activecontent__githubrepoheader">
<a href="/Users/benhalpern/dev/dev.to_core/app"></a>
invalid json response body at reason: Unexpected end of JSON input
</div>
<div class="activecontent__githubrepofiles activecontent__githubrepofiles--root">
<div class="activecontent__githubrepofilerow">
<a
href="https://github.com/username/repositoryname/tree/master/Camera"
data-api-url="https://api.github.com/repos/username/repositoryname/contents/Camera?ref=master"
data-path="Camera"
onClick={[Function anonymous]}
>
<span
role="img"
aria-label="folder-emoji"
>
📁
</span>
Camera
</a>
</div>
<div class="activecontent__githubrepofilerow">
<a
href="https://github.com/username/repositoryname/tree/master/Environment"
data-api-url="https://api.github.com/repos/username/repositoryname/contents/Environment?ref=master"
data-path="Environment"
onClick={[Function anonymous]}
>
<span
role="img"
aria-label="folder-emoji"
>
📁
</span>
Environment
</a>
</div>
<div class="activecontent__githubrepofilerow">
<a
href="https://github.com/username/repositoryname/tree/master/Interactables"
data-api-url="https://api.github.com/repos/username/repositoryname/contents/Interactables?ref=master"
data-path="Interactables"
onClick={[Function anonymous]}
>
<span
role="img"
aria-label="folder-emoji"
>
📁
</span>
Interactables
</a>
</div>
<div class="activecontent__githubrepofilerow">
<a
href="https://github.com/username/repositoryname/tree/master/Level%20Design"
data-api-url="https://api.github.com/repos/username/repositoryname/contents/Level%20Design?ref=master"
data-path="Level Design"
onClick={[Function anonymous]}
>
Level Design
</a>
</div>
<div class="activecontent__githubrepofilerow">
<a
href="https://github.com/username/repositoryname/tree/master/Player"
data-api-url="https://api.github.com/repos/username/repositoryname/contents/Player?ref=master"
data-path="Player"
onClick={[Function anonymous]}
>
Player
</a>
</div>
</div>
</div>
`;
exports[`<GithubRepo /> with no token should render and test snapshot 1`] = `
<div
class="activecontent__githubrepo"
>
<div
class="activecontent__githubrepoheader"
>
<em>
Authentication required
</em>
</div>
<p>
This feature is in internal alpha testing mode.
</p>
</div>
`;

View file

@ -1,460 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<UserDetails /> for user1 should render and test snapshot 1`] = `
<div>
<img
alt="bojackhorseman profile"
src="https://media.giphy.com/media/3o7WTHb2WjEXbsmWDS/giphy.gif"
style={
Object {
"borderRadius": "500px",
"display": "block",
"height": "210px",
"margin": " 15px auto",
"width": "210px",
}
}
/>
<h1
style={
Object {
"textAlign": "center",
}
}
>
<a
href="/bojackhorseman"
rel="noopener noreferrer"
target="_blank"
>
Bojack Horseman
</a>
</h1>
<div
style={
Object {
"height": "50px",
"margin": "auto",
"width": "96%",
}
}
>
<a
href="https://twitter.com/bojacktwitter"
rel="noopener noreferrer"
target="_blank"
>
<img
alt="twitter logo"
src=""
style={
Object {
"margin": "5px 15px 15px 0px",
"width": "30px",
}
}
/>
</a>
<a
href="https://github.com/bojackgithub"
rel="noopener noreferrer"
target="_blank"
>
<img
alt="github logo"
src=""
style={
Object {
"margin": "5px 15px 15px 0px",
"width": "30px",
}
}
/>
</a>
<a
href="http://bojackhorseman.com"
rel="noopener noreferrer"
target="_blank"
>
<img
alt="external link icon"
class="external-link-img"
src=""
style={
Object {
"margin": "5px 15px 15px 0px",
"width": "30px",
}
}
/>
</a>
</div>
<div
style={
Object {
"fontStyle": "italic",
}
}
>
I am the Bojack Horseman from Horsing Around and Secreteriat
</div>
<div
class="activechatchannel__activecontentuserdetails"
>
<div>
<div
class="key"
>
location
</div>
<div
class="value"
>
Los Angeles, CA
</div>
</div>
<div
class="key"
>
joined
</div>
<div
class="value"
>
January 2, 1964
</div>
</div>
<div
class="userdetails__blockreport"
/>
<div
id="userdetails__reportabuse"
style={
Object {
"display": "none",
}
}
>
<div
class="userdetails__reportabuse"
>
<p>
Reporting abuse will:
</p>
<ul>
<li>
close this chat and prevent this user from re-opening chat with you
</li>
<li>
give the DEV team your consent to read messages in this chat to understand your report and take appropriate action
</li>
</ul>
<p>
Blocking is only on Connect right now and has not been implemented across DEV yet.
</p>
<h5>
Are you sure?
</h5>
<a
href="/report-abuse"
onClick={[Function]}
tabIndex="0"
>
Yes, Report
</a>
<a
class="no"
onClick={[Function]}
onKeyUp={[Function]}
role="button"
tabIndex="0"
>
No
</a>
</div>
</div>
<div
id="userdetails__blockmsg"
style={
Object {
"display": "none",
}
}
>
<div
class="userdetails__blockmsg"
>
<p>
Blocking on connect will:
</p>
<ul>
<li>
close this chat and prevent this user from re-opening chat with you
</li>
<li>
NOT notify the user you will block--this channel will become inaccessible for both users
</li>
</ul>
<p>
Blocking is only on Connect right now and has not been implemented across DEV yet. Consider reporting abuse to the DEV team if this user is spamming or harassing elsewhere on dev.to, so we can take further action.
</p>
<h5>
Are you sure?
</h5>
<a
onClick={[Function]}
onKeyUp={[Function]}
role="button"
tabIndex="0"
>
Yes, Block
</a>
<a
class="no"
onClick={[Function]}
onKeyUp={[Function]}
role="button"
tabIndex="0"
>
No
</a>
</div>
</div>
</div>
`;
exports[`<UserDetails /> for user2 should render and test snapshot 1`] = `
<div>
<img
alt="mrpeanutbutter profile"
src="https://media.giphy.com/media/xThuW6sWCGbpZMpX7a/giphy.gif"
style={
Object {
"borderRadius": "500px",
"display": "block",
"height": "210px",
"margin": " 15px auto",
"width": "210px",
}
}
/>
<h1
style={
Object {
"textAlign": "center",
}
}
>
<a
href="/mrpeanutbutter"
rel="noopener noreferrer"
target="_blank"
>
Mr. Peanutbutter
</a>
</h1>
<div
style={
Object {
"height": "50px",
"margin": "auto",
"width": "96%",
}
}
>
<a
href="https://twitter.com/mrpbtwitter"
rel="noopener noreferrer"
target="_blank"
>
<img
alt="twitter logo"
src=""
style={
Object {
"margin": "5px 15px 15px 0px",
"width": "30px",
}
}
/>
</a>
<a
href="https://github.com/mrpbgithub"
rel="noopener noreferrer"
target="_blank"
>
<img
alt="github logo"
src=""
style={
Object {
"margin": "5px 15px 15px 0px",
"width": "30px",
}
}
/>
</a>
<a
href="http://mrpeanutbutter.com"
rel="noopener noreferrer"
target="_blank"
>
<img
alt="external link icon"
class="external-link-img"
src=""
style={
Object {
"margin": "5px 15px 15px 0px",
"width": "30px",
}
}
/>
</a>
</div>
<div
style={
Object {
"fontStyle": "italic",
}
}
>
Woof Woof *smile*
</div>
<div
class="activechatchannel__activecontentuserdetails"
>
<div>
<div
class="key"
>
location
</div>
<div
class="value"
>
Los Angeles, DO(G)
</div>
</div>
<div
class="key"
>
joined
</div>
<div
class="value"
>
1960s
</div>
</div>
<div
class="userdetails__blockreport"
>
<button
onClick={[Function]}
type="button"
>
Block User
</button>
<button
onClick={[Function]}
type="button"
>
Report Abuse
</button>
</div>
<div
id="userdetails__reportabuse"
style={
Object {
"display": "none",
}
}
>
<div
class="userdetails__reportabuse"
>
<p>
Reporting abuse will:
</p>
<ul>
<li>
close this chat and prevent this user from re-opening chat with you
</li>
<li>
give the DEV team your consent to read messages in this chat to understand your report and take appropriate action
</li>
</ul>
<p>
Blocking is only on Connect right now and has not been implemented across DEV yet.
</p>
<h5>
Are you sure?
</h5>
<a
href="/report-abuse"
onClick={[Function]}
tabIndex="0"
>
Yes, Report
</a>
<a
class="no"
onClick={[Function]}
onKeyUp={[Function]}
role="button"
tabIndex="0"
>
No
</a>
</div>
</div>
<div
id="userdetails__blockmsg"
style={
Object {
"display": "none",
}
}
>
<div
class="userdetails__blockmsg"
>
<p>
Blocking on connect will:
</p>
<ul>
<li>
close this chat and prevent this user from re-opening chat with you
</li>
<li>
NOT notify the user you will block--this channel will become inaccessible for both users
</li>
</ul>
<p>
Blocking is only on Connect right now and has not been implemented across DEV yet. Consider reporting abuse to the DEV team if this user is spamming or harassing elsewhere on dev.to, so we can take further action.
</p>
<h5>
Are you sure?
</h5>
<a
onClick={[Function]}
onKeyUp={[Function]}
role="button"
tabIndex="0"
>
Yes, Block
</a>
<a
class="no"
onClick={[Function]}
onKeyUp={[Function]}
role="button"
tabIndex="0"
>
No
</a>
</div>
</div>
</div>
`;

View file

@ -1,78 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<View /> should render and test snapshot (no channel) 1`] = `
<div
class="chatNonChatView"
>
<div
class="container"
>
<button
class="chatNonChatView_exitbutton"
data-content="exit"
onClick={[Function]}
type="button"
>
×
</button>
<h1>
Channel Invitations
<span
aria-label="hugging-emoji"
role="img"
>
🤗
</span>
</h1>
</div>
</div>
`;
exports[`<View /> should render and test snapshot (with channel) 1`] = `
preact-render-spy (1 nodes)
-------
<div class="chatNonChatView">
<div class="container">
<button
type="button"
class="chatNonChatView_exitbutton"
onClick={[Function onViewExitFake]}
data-content="exit"
>
×
</button>
<h1>
Channel Invitations
<span
role="img"
aria-label="hugging-emoji"
>
🤗
</span>
</h1>
<div class="chatNonChatView_contentblock">
<h2>name</h2>
<div>
<em>some description</em>
</div>
<button
type="button"
class="cta"
onClick={[Function handleInvitationAcceptFake]}
data-content="12345"
>
Accept
</button>
<button
type="button"
class="cta"
onClick={[Function handleInvitationDeclineFake]}
data-content="12345"
>
Decline
</button>
</div>
</div>
</div>
`;

View file

@ -1,14 +0,0 @@
import { h } from 'preact';
import render from 'preact-render-to-json';
import CodeEditor from '../codeEditor';
const getCodeEditor = () => (
<CodeEditor activeChannelId={12345} pusherKey="ASDFGHJKL" />
);
describe('<CodeEditor />', () => {
it('should render and test snapshot', () => {
const tree = render(getCodeEditor());
expect(tree).toMatchSnapshot();
});
});

View file

@ -1,179 +0,0 @@
import { h } from 'preact';
import render from 'preact-render-to-json';
import { shallow } from 'preact-render-spy';
import fetch from 'jest-fetch-mock';
import GithubRepo from '../githubRepo';
global.fetch = fetch;
function flushPromises() {
return new Promise(resolve => setImmediate(resolve));
}
const getGithubRepo = token => (
<GithubRepo
activeChannelId={12345}
pusherKey="ASDFGHJKL"
githubToken={token}
resource={{ args: 'someargs' }}
/>
);
const contents = [
{
name: 'Camera',
path: 'Camera',
sha: 'hysst5jI2idHutihWo3JxYlTByoj0lkdXmkmuBEp',
size: 0,
url:
'https://api.github.com/repos/username/repositoryname/contents/Camera?ref=master',
html_url: 'https://github.com/username/repositoryname/tree/master/Camera',
git_url:
'https://api.github.com/repos/username/repositoryname/git/trees/hysst5jI2idHutihWo3JxYlTByoj0lkdXmkmuBEp',
download_url: null,
type: 'dir',
_links: {
self:
'https://api.github.com/repos/username/repositoryname/contents/Camera?ref=master',
git:
'https://api.github.com/repos/username/repositoryname/git/trees/hysst5jI2idHutihWo3JxYlTByoj0lkdXmkmuBEp',
html: 'https://github.com/username/repositoryname/tree/master/Camera',
},
},
{
name: 'Environment',
path: 'Environment',
sha: 'dwogYlYGQOXj1ru3L9HYfX7HdX3WNQPJgJVeStRs',
size: 0,
url:
'https://api.github.com/repos/username/repositoryname/contents/Environment?ref=master',
html_url:
'https://github.com/username/repositoryname/tree/master/Environment',
git_url:
'https://api.github.com/repos/username/repositoryname/git/trees/dwogYlYGQOXj1ru3L9HYfX7HdX3WNQPJgJVeStRs',
download_url: null,
type: 'dir',
_links: {
self:
'https://api.github.com/repos/username/repositoryname/contents/Environment?ref=master',
git:
'https://api.github.com/repos/username/repositoryname/git/trees/dwogYlYGQOXj1ru3L9HYfX7HdX3WNQPJgJVeStRs',
html:
'https://github.com/username/repositoryname/tree/master/Environment',
},
},
{
name: 'Interactables',
path: 'Interactables',
sha: '44OLxtYSQjr2DLVKPnwGTj6JuQpo7Te7pEIDULat',
size: 0,
url:
'https://api.github.com/repos/username/repositoryname/contents/Interactables?ref=master',
html_url:
'https://github.com/username/repositoryname/tree/master/Interactables',
git_url:
'https://api.github.com/repos/username/repositoryname/git/trees/44OLxtYSQjr2DLVKPnwGTj6JuQpo7Te7pEIDULat',
download_url: null,
type: 'dir',
_links: {
self:
'https://api.github.com/repos/username/repositoryname/contents/Interactables?ref=master',
git:
'https://api.github.com/repos/username/repositoryname/git/trees/44OLxtYSQjr2DLVKPnwGTj6JuQpo7Te7pEIDULat',
html:
'https://github.com/username/repositoryname/tree/master/Interactables',
},
},
{
name: 'Level Design',
path: 'Level Design',
sha: 'g178Oy2xV8gqFBhoaflJkSbHN01dxWEqjJTxw6Ax',
size: 583,
url:
'https://api.github.com/repos/username/repositoryname/contents/Level%20Design?ref=master',
html_url:
'https://github.com/username/repositoryname/tree/master/Level%20Design',
git_url:
'https://api.github.com/repos/username/repositoryname/git/trees/g178Oy2xV8gqFBhoaflJkSbHN01dxWEqjJTxw6Ax',
download_url:
'https://raw.githubusercontent.com/username/repositoryname/master/Level%20Design',
type: 'file',
_links: {
self:
'https://api.github.com/repos/username/repositoryname/contents/Level%20Design?ref=master',
git:
'https://api.github.com/repos/username/repositoryname/git/trees/g178Oy2xV8gqFBhoaflJkSbHN01dxWEqjJTxw6Ax',
html:
'https://github.com/username/repositoryname/tree/master/Level%20Design',
},
},
{
name: 'Player',
path: 'Player',
sha: 'tia6ISq19krmOJzbtwpCTIuwRnvpSxZY2g1FMgOp',
size: 1000,
url:
'https://api.github.com/repos/username/repositoryname/contents/Player?ref=master',
html_url: 'https://github.com/username/repositoryname/tree/master/Player',
git_url:
'https://api.github.com/repos/username/repositoryname/git/trees/tia6ISq19krmOJzbtwpCTIuwRnvpSxZY2g1FMgOp',
download_url:
'https://raw.githubusercontent.com/username/repositoryname/master/Player',
type: 'file',
_links: {
self:
'https://api.github.com/repos/username/repositoryname/contents/Player?ref=master',
git:
'https://api.github.com/repos/username/repositoryname/git/trees/tia6ISq19krmOJzbtwpCTIuwRnvpSxZY2g1FMgOp',
html: 'https://github.com/username/repositoryname/tree/master/Player',
},
},
];
describe('<GithubRepo />', () => {
describe('with no token', () => {
it('should render and test snapshot', () => {
const tree = render(getGithubRepo());
expect(tree).toMatchSnapshot();
});
it('should have the proper elements, attributes and values', () => {
const context = shallow(getGithubRepo());
expect(context.find('.activecontent__githubrepo').exists()).toEqual(true);
expect(context.find('em').text()).toEqual('Authentication required');
});
});
describe('with fake token', () => {
it('should render and test snapshot', async () => {
await fetch.mockResponseOnce(JSON.stringify(contents));
const context = shallow(getGithubRepo('some_token'));
await flushPromises();
expect(context).toMatchSnapshot();
});
it('should have the proper elements, attributes and values with states set', async () => {
await fetch.mockResponseOnce(JSON.stringify(contents));
const context = shallow(getGithubRepo('some_token'));
await flushPromises();
const len = context.find('.activecontent__githubrepofilerow').length;
expect(len > 0).toEqual(true);
for (let i = 0; i < len; i += 1) {
const ghrow = context.find('.activecontent__githubrepofilerow').at(i);
expect(ghrow.exists()).toEqual(true);
if (contents[i].type === 'dir') {
expect(ghrow.text()).toEqual(`📁 ${contents[i].name}`);
} else {
expect(ghrow.text()).toEqual(contents[i].name);
}
expect(ghrow.childAt(0).attr('href')).toEqual(contents[i].html_url);
expect(ghrow.childAt(0).attr('data-api-url')).toEqual(contents[i].url);
expect(ghrow.childAt(0).attr('data-path')).toEqual(contents[i].path);
}
expect(context.find('.activecontent__githubrepoheader').text()).toEqual(
context.state('path'),
);
});
});
});

View file

@ -1,211 +0,0 @@
import { h } from 'preact';
import render from 'preact-render-to-json';
import { JSDOM } from 'jsdom';
import { shallow } from 'preact-render-spy';
import UserDetails from '../userDetails';
const doc = new JSDOM('<!doctype html><html><body></body></html>');
global.document = doc;
global.window = doc.defaultView;
global.window.currentUser = { id: '1' };
const user1 = {
id: '1',
username: 'bojackhorseman',
name: 'Bojack Horseman',
summary: 'I am the Bojack Horseman from Horsing Around and Secreteriat',
joined_at: 'January 2, 1964',
twitter_username: 'bojacktwitter',
github_username: 'bojackgithub',
website_url: 'http://bojackhorseman.com',
location: 'Los Angeles, CA',
profile_image: 'https://media.giphy.com/media/3o7WTHb2WjEXbsmWDS/giphy.gif',
};
const user2 = {
id: '2',
username: 'mrpeanutbutter',
name: 'Mr. Peanutbutter',
summary: 'Woof Woof *smile*',
joined_at: '1960s',
twitter_username: 'mrpbtwitter',
github_username: 'mrpbgithub',
website_url: 'http://mrpeanutbutter.com',
location: 'Los Angeles, DO(G)',
profile_image: 'https://media.giphy.com/media/xThuW6sWCGbpZMpX7a/giphy.gif',
};
const channel = { channel_type: 'direct', id: 2 };
const getUserDetails = user => (
<UserDetails user={user} activeChannel={channel} activeChannelId={2} />
);
describe('<UserDetails />', () => {
describe('for user1', () => {
it('should render and test snapshot', () => {
const tree = render(getUserDetails(user1));
expect(tree).toMatchSnapshot();
});
it('should have the appropriate elements, attributes and values', () => {
const context = shallow(getUserDetails(user1));
expect(
context.find('.activechatchannel__activecontentuserdetails').exists(),
).toEqual(true); // only class to check for
const parentDiv = context.find('div').at(0);
expect(parentDiv.childAt(0)).toEqual(context.find('img').at(0));
expect(
context
.find('img')
.at(0)
.attr('src'),
).toEqual(user1.profile_image); // profile pic
expect(parentDiv.childAt(1)).toEqual(context.find('h1'));
expect(context.find('h1').text()).toEqual(user1.name); // user.name
expect(
context
.find('a')
.at(0)
.attr('href'),
).toEqual(`/${user1.username}`); // user.username
expect(
context
.find('.userdetails__blockreport')
.at(0)
.children()[0],
).toEqual('');
// social links
expect(
context
.find('a')
.at(1)
.attr('href'),
).toEqual(`https://twitter.com/${user1.twitter_username}`); // twitter
expect(
context
.find('a')
.at(2)
.attr('href'),
).toEqual(`https://github.com/${user1.github_username}`); // github
expect(
context
.find('a')
.at(3)
.attr('href'),
).toEqual(user1.website_url); // website
expect(parentDiv.childAt(3)).toEqual(context.find('div').at(2));
expect(
context
.find('div')
.at(2)
.text(),
).toEqual(user1.summary); // user.summary
expect(
context
.find('div')
.at(6)
.text(),
).toEqual(user1.location); // user.location
expect(
context
.find('div')
.at(8)
.text(),
).toEqual(user1.joined_at); // user.joined_at
});
});
describe('for user2', () => {
it('should render and test snapshot', () => {
const tree = render(getUserDetails(user2));
expect(tree).toMatchSnapshot();
});
it('should have the appropriate elements, attributes and values', () => {
const context = shallow(getUserDetails(user2));
expect(
context.find('.activechatchannel__activecontentuserdetails').exists(),
).toEqual(true); // only class to check for
const parentDiv = context.find('div').at(0);
expect(parentDiv.childAt(0)).toEqual(context.find('img').at(0));
expect(
context
.find('img')
.at(0)
.attr('src'),
).toEqual(user2.profile_image); // profile pic
expect(parentDiv.childAt(1)).toEqual(context.find('h1'));
expect(context.find('h1').text()).toEqual(user2.name); // user.name
expect(
context
.find('a')
.at(0)
.attr('href'),
).toEqual(`/${user2.username}`); // user.username
expect(
parentDiv
.find('.userdetails__blockreport')
.at(0)
.childAt(0)
.text(),
).toEqual('Block User');
expect(
parentDiv
.find('.userdetails__blockreport')
.at(0)
.childAt(1)
.text(),
).toEqual('Report Abuse');
// social links
expect(
context
.find('a')
.at(1)
.attr('href'),
).toEqual(`https://twitter.com/${user2.twitter_username}`); // twitter
expect(
context
.find('a')
.at(2)
.attr('href'),
).toEqual(`https://github.com/${user2.github_username}`); // github
expect(
context
.find('a')
.at(3)
.attr('href'),
).toEqual(user2.website_url); // website
expect(parentDiv.childAt(3)).toEqual(context.find('div').at(2));
expect(
context
.find('div')
.at(2)
.text(),
).toEqual(user2.summary); // user.summary
expect(
context
.find('div')
.at(6)
.text(),
).toEqual(user2.location); // user.location
expect(
context
.find('div')
.at(8)
.text(),
).toEqual(user2.joined_at); // user.joined_at
});
});
});

View file

@ -1,139 +0,0 @@
import { h } from 'preact';
import render from 'preact-render-to-json';
import { deep } from 'preact-render-spy';
import View from '../view';
let exited = false;
let accepted = false;
let declined = false;
const onViewExitFake = () => {
exited = true;
};
const handleInvitationAcceptFake = () => {
accepted = true;
};
const handleInvitationDeclineFake = () => {
declined = true;
};
const sampleChannel = [
{
channel_name: 'name',
description: 'some description',
membership_id: '12345',
},
];
const getView = channel => (
<View
channels={channel}
onViewExit={onViewExitFake}
onAcceptInvitation={handleInvitationAcceptFake}
onDeclineInvitation={handleInvitationDeclineFake}
/>
);
describe('<View />', () => {
it('should render and test snapshot (no channel)', () => {
const tree = render(getView([]));
expect(tree).toMatchSnapshot();
});
it('should render and test snapshot (with channel)', () => {
const tree = deep(getView(sampleChannel), { depth: 2 });
expect(tree).toMatchSnapshot();
});
it('should have the proper attributes and text values (no channel provided)', () => {
const context = deep(getView([]), { depth: 2 });
expect(context.find('.chatNonChatView').exists()).toEqual(true);
expect(context.find('.container').exists()).toEqual(true);
expect(context.find('.chatNonChatView_exitbutton').exists()).toEqual(true);
expect(context.find('.chatNonChatView_exitbutton').text()).toEqual('×');
expect(context.find('h1').exists()).toEqual(true);
expect(context.find('h1').text()).toEqual('Channel Invitations 🤗');
expect(context.find('.chatNonChatView_contentblock').exists()).toEqual(
false,
);
});
it('should have the proper attributes and text values (with channel provided)', () => {
const context = deep(getView(sampleChannel), { depth: 2 });
expect(context.find('.chatNonChatView_contentblock').exists()).toEqual(
true,
);
expect(context.find('h2').exists()).toEqual(true);
expect(context.find('h2').text()).toEqual('name');
expect(context.find('em').exists()).toEqual(true);
expect(context.find('em').text()).toEqual('some description');
expect(context.find('.cta').exists()).toEqual(true);
expect(
context
.find('.cta')
.at(0)
.attr('data-content'),
).toEqual('12345'); // accept button
expect(
context
.find('.cta')
.at(0)
.text(),
).toEqual('Accept'); // accept button
expect(
context
.find('.cta')
.at(1)
.attr('data-content'),
).toEqual('12345'); // decline button
expect(
context
.find('.cta')
.at(1)
.text(),
).toEqual('Decline'); // accept button
});
it('should trigger exit', () => {
const context = deep(getView([]), { depth: 2 });
context.find('.chatNonChatView_exitbutton').simulate('click');
expect(exited).toEqual(true);
exited = false;
});
it('should trigger accept', () => {
const context = deep(getView(sampleChannel), { depth: 2 });
context
.find('.cta')
.at(0)
.simulate('click'); // click accept button
expect(exited).toEqual(false);
expect(accepted).toEqual(true);
expect(declined).toEqual(false);
accepted = false;
});
it('should trigger decline', () => {
const context = deep(getView(sampleChannel), { depth: 2 });
context
.find('.cta')
.at(1)
.simulate('click'); // click decline button
expect(exited).toEqual(false);
expect(accepted).toEqual(false);
expect(declined).toEqual(true);
declined = false;
});
});

View file

@ -21,7 +21,6 @@ import Compose from './compose';
import Message from './message';
import Content from './content';
import Video from './video';
import View from './view';
import setupPusher from '../src/utils/pusher';
import debounceAction from '../src/utils/debounceAction';
@ -67,7 +66,6 @@ export default class Chat extends Component {
activeVideoChannelId: null,
incomingVideoCallChannelIds: [],
videoCallParticipants: [],
nonChatView: null,
inviteChannels: [],
soundOn: true,
videoOn: true,
@ -676,7 +674,12 @@ export default class Chat extends Component {
};
triggerSwitchChannel = (id, slug) => {
const { chatChannels, isMobileDevice, unopenedChannelIds } = this.state;
const {
chatChannels,
isMobileDevice,
unopenedChannelIds,
activeChannelId,
} = this.state;
const newUnopenedChannelIds = unopenedChannelIds;
const index = newUnopenedChannelIds.indexOf(id);
if (index > -1) {
@ -692,6 +695,17 @@ export default class Chat extends Component {
),
});
this.setupChannel(id);
const params = new URLSearchParams(window.location.search);
if (params.get('ref') === 'group_invite') {
this.setActiveContentState(activeChannelId, {
type_of: 'loading-post',
});
this.setActiveContent({
path: '/chat_channel_memberships',
type_of: 'article',
});
}
window.history.replaceState(null, null, `/connect/${slug}`);
if (!isMobileDevice) {
document.getElementById('messageform').focus();
@ -765,7 +779,6 @@ export default class Chat extends Component {
) {
return false;
}
const { target } = e;
const content =
target.dataset.content || target.parentElement.dataset.content;
@ -773,7 +786,7 @@ export default class Chat extends Component {
e.preventDefault();
e.stopPropagation();
const { activeChannelId } = this.state;
const { activeChannelId, activeChannel } = this.state;
if (target.dataset.content.startsWith('chat_channels/')) {
this.setActiveContentState(activeChannelId, {
type_of: 'loading-user',
@ -783,6 +796,14 @@ export default class Chat extends Component {
this.setActiveContent,
null,
);
} else if (target.dataset.content === 'sidecar_all') {
this.setActiveContentState(activeChannelId, {
type_of: 'loading-post',
});
this.setActiveContent({
path: `/chat_channel_memberships/${activeChannel.id}/edit`,
type_of: 'article',
});
} else if (
content.startsWith('sidecar') ||
content.startsWith('article')
@ -866,14 +887,6 @@ export default class Chat extends Component {
getChannels(filterQuery, null, this.props, 0, filters, this.loadChannels);
};
triggerNonChatView = e => {
this.setState({ nonChatView: e.target.dataset.content });
};
triggerExitView = () => {
this.setState({ nonChatView: null });
};
handleFailure = err => {
// eslint-disable-next-line no-console
console.error(err);
@ -1012,13 +1025,17 @@ export default class Chat extends Component {
if (state.inviteChannels.length > 0) {
invitesButton = (
<div className="chat__channelinvitationsindicator">
<button
onClick={this.triggerNonChatView}
data-content="invitations"
<a
href="/chat_channel_memberships"
onClick={this.triggerActiveContent}
data-content="sidecar-chat_channel_memberships"
type="button"
>
<span role="img" aria-label="emoji">
👋
</span>
New Invitations!
</button>
</a>
</div>
);
}
@ -1273,9 +1290,9 @@ export default class Chat extends Component {
let before = text.substring(0, start);
before = text.substring(0, before.lastIndexOf('@') + 1);
const after = text.substring(end, text.length);
el.value = before + name + after;
el.selectionStart = start + name.length;
el.selectionEnd = start + name.length;
el.value = `${before + name } ${ after}`;
el.selectionStart = start + name.length + 1;
el.selectionEnd = start + name.length + 1;
el.focus();
this.setState({ showMemberlist: false });
};
@ -1441,7 +1458,7 @@ export default class Chat extends Component {
};
renderChannelHeaderInner = () => {
const { activeChannel, activeChannelId } = this.state;
const { activeChannel } = this.state;
if (activeChannel.channel_type === 'direct') {
return (
<a
@ -1455,9 +1472,9 @@ export default class Chat extends Component {
}
return (
<a
href={`/connect/${activeChannel.channel_modified_slug}`}
href={`/chat_channel_memberships/${activeChannel.id}/edit`}
onClick={this.triggerActiveContent}
data-content={`chat_channels/${activeChannelId}`}
data-content="sidecar-chat_channel_membership"
>
{activeChannel.channel_name}
</a>
@ -1466,7 +1483,6 @@ export default class Chat extends Component {
renderChannelConfigImage = () => {
const { activeContent, activeChannel, activeChannelId } = this.state;
if (
activeContent[activeChannelId] &&
activeContent[activeChannelId].type_of
@ -1477,7 +1493,12 @@ export default class Chat extends Component {
const dataContent =
activeChannel.channel_type === 'direct'
? 'sidecar-user'
: `chat_channels/${activeChannelId}`;
: `sidecar-chat_channel_membership`;
const path =
activeChannel.channel_type === 'direct'
? `/${activeChannel.channel_username}`
: `/chat_channel_memberships/${activeChannel.id}/edit`;
return (
<a
@ -1487,7 +1508,7 @@ export default class Chat extends Component {
if (e.keyCode === 13) this.triggerActiveContent(e);
}}
tabIndex="0"
href={`/${activeChannel.channel_username}`}
href={path}
data-content={dataContent}
>
<img
@ -1551,17 +1572,6 @@ export default class Chat extends Component {
</div>
);
}
let nonChatView = '';
if (state.nonChatView) {
nonChatView = (
<View
channels={state.inviteChannels}
onViewExit={this.triggerExitView}
onAcceptInvitation={this.handleInvitationAccept}
onDeclineInvitation={this.handleInvitationDecline}
/>
);
}
return (
<div
className={`chat chat--${
@ -1572,7 +1582,6 @@ export default class Chat extends Component {
{this.renderChatChannels()}
<div className="chat__activechat">
{vid}
{nonChatView}
{this.renderActiveChatChannel(channelHeader, incomingCall)}
</div>
</div>

View file

@ -1,49 +0,0 @@
import { h, Component } from 'preact';
import PropTypes from 'prop-types';
export default class CodeEditor extends Component {
static propTypes = {
activeChannelId: PropTypes.string.isRequired,
};
componentDidMount() {
const { activeChannelId } = this.props;
import('codemirror').then(CodeMirror => {
const editor = document.getElementById('codeeditor');
const myCodeMirror = CodeMirror(editor, {
mode: 'javascript',
theme: 'material',
autofocus: true,
});
myCodeMirror.setSize('100%', '100%');
// Initial trigger:
const channel = window.pusher.channel(
`presence-channel-${activeChannelId}`,
);
channel.trigger('client-livecode', {
context: 'initializing-live-code-channel',
channel: `presence-channel-${activeChannelId}`,
});
// Coding trigger:
myCodeMirror.on('keyup', cm => {
channel.trigger('client-livecode', {
keyPressed: true,
value: cm.getValue(),
cursorPos: cm.getCursor(),
});
});
});
}
shouldComponentUpdate() {
return false;
}
render() {
return (
<div id="codeeditor" className="chatcodeeditor">
<div className="chatcodeeditor__header">Experimental (WIP)</div>
</div>
);
}
}

View file

@ -1,11 +1,8 @@
import { h, Component } from 'preact';
import PropTypes from 'prop-types';
import CodeEditor from './codeEditor';
import GithubRepo from './githubRepo';
import ChannelDetails from './channelDetails';
import UserDetails from './userDetails';
import Article from './article';
export default class Content extends Component {
static propTypes = {
resource: PropTypes.object,
@ -50,55 +47,7 @@ function display(props) {
/>
);
}
if (props.resource.type_of === 'loading-user') {
return (
<div
style={{
height: '25px',
width: '96%',
margin: ' 8px auto',
display: 'block',
backgroundColor: '#f5f6f7',
}}
/>
);
}
if (props.resource.type_of === 'user') {
return (
<UserDetails
user={props.resource}
activeChannelId={props.activeChannelId}
activeChannel={props.activeChannel}
/>
);
}
if (props.resource.type_of === 'article') {
return <Article resource={props.resource} />;
}
if (props.resource.type_of === 'github') {
return (
<GithubRepo
activeChannelId={props.activeChannelId}
pusherKey={props.pusherKey}
githubToken={props.githubToken}
resource={props.resource}
/>
);
}
if (props.resource.type_of === 'chat_channel') {
return (
<ChannelDetails
channel={props.resource}
activeChannelId={props.activeChannelId}
/>
);
}
if (props.resource.type_of === 'code_editor') {
return (
<CodeEditor
activeChannelId={props.activeChannelId}
pusherKey={props.pusherKey}
/>
);
}
}

View file

@ -1,185 +0,0 @@
import { h, Component } from 'preact';
import PropTypes from 'prop-types';
import marked from 'marked';
import { getJSONContents } from './actions';
export default class GithubRepo extends Component {
static propTypes = {
githubToken: PropTypes.string.isRequired,
resource: PropTypes.shape({
args: PropTypes.string,
}).isRequired,
};
constructor(props) {
super(props);
this.state = {
root: true,
directories: [],
files: [],
readme: null,
content: null,
token: props.githubToken,
path: null,
};
}
componentDidMount() {
const { token } = this.state;
const { resource } = this.props;
if (token) {
getJSONContents(
`https://api.github.com/repos/${resource.args}/contents?access_token=${token}`,
this.loadContent,
this.loadFailure,
);
getJSONContents(
`https://api.github.com/repos/${resource.args}/readme?access_token=${token}`,
this.loadContent,
this.loadFailure,
);
}
this.setState({ path: resource.args });
}
handleItemClick = e => {
const { token } = this.state;
e.preventDefault();
getJSONContents(
`${e.target.dataset.apiUrl}&access_token=${token}`,
this.loadContent,
this.loadFailure,
);
this.setState({
root: false,
path: e.target.dataset.path,
});
};
loadContent = response => {
const files = [];
const directories = [];
if (response.message === 'Not Found') {
this.setState({ path: 'Repo not found (misspelled or private?)' });
} else if (Array.isArray(response)) {
response.forEach(item => {
if (item.type === 'file') {
files.push(item);
} else {
directories.push(item);
}
});
this.setState({
files,
directories,
});
} else if (response.path === 'README.md') {
this.setState({
readme: window.atob(response.content),
});
} else if (response.content) {
this.setState({
content: window.atob(response.content),
});
}
};
loadFailure = response => {
this.setState({ path: response.message });
};
render() {
const {
token,
content,
path,
directories: directoriesFromState,
files: filesFromState,
readme: readmeFromState,
root,
} = this.state;
if (!token || token.length === 0) {
return (
<div className="activecontent__githubrepo">
<div className="activecontent__githubrepoheader">
<em>Authentication required</em>
</div>
<p>This feature is in internal alpha testing mode.</p>
</div>
);
}
if (content) {
return (
<div className="activecontent__githubrepo">
<div className="activecontent__githubrepoheader">{path}</div>
<pre>{content}</pre>
</div>
);
}
const directories = directoriesFromState.map(item => (
<div className="activecontent__githubrepofilerow">
<a
href={item.html_url}
data-api-url={item.url}
data-path={item.path}
onClick={this.handleItemClick}
>
<span role="img" aria-label="folder-emoji">
📁
</span>
{' '}
{item.name}
</a>
</div>
));
const files = filesFromState.map(item => (
<div className="activecontent__githubrepofilerow">
<a
href={item.html_url}
data-api-url={item.url}
data-path={item.path}
onClick={this.handleItemClick}
>
{item.name}
</a>
</div>
));
let readme = '';
if (readmeFromState) {
readme = (
<div
className="activecontent__githubreporeadme"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: marked(readmeFromState),
}}
/>
);
}
if (root) {
return (
<div className="activecontent__githubrepo">
<div className="activecontent__githubrepoheader">
{/* eslint-disable-next-line jsx-a11y/anchor-has-content */}
<a href="/Users/benhalpern/dev/dev.to_core/app" />
{path}
</div>
<div className="activecontent__githubrepofiles activecontent__githubrepofiles--root">
{directories}
{files}
</div>
{readme}
</div>
);
}
return (
<div className="activecontent__githubrepo">
<div className="activecontent__githubrepoheader">{path}</div>
<div className="activecontent__githubrepofiles">
{directories}
{files}
</div>
</div>
);
}
}

View file

@ -1,271 +0,0 @@
import { h } from 'preact';
import { PropTypes } from 'prop-types';
// eslint-disable-next-line import/no-unresolved
import twitterImage from 'images/twitter-logo.svg';
// eslint-disable-next-line import/no-unresolved
import githubImage from 'images/github-logo.svg';
// eslint-disable-next-line import/no-unresolved
import websiteImage from 'images/external-link-logo.svg';
function blockUser(blockedUserId) {
const body = {
user_block: {
blocked_id: blockedUserId,
},
};
getCsrfToken().then(sendFetch('block-user', JSON.stringify(body)));
}
const setUpButton = ({ modalId = '', otherModalId = '', btnName = '' }) => {
return (
<button
type="button"
onClick={() => {
const modal = document.getElementById(`${modalId}`);
const otherModal = document.getElementById(`${otherModalId}`);
otherModal.style.display = 'none';
if (modal.style.display === 'none') {
modal.style.display = 'block';
window.location.href = `#${modalId}`;
} else {
modal.style.display = 'none';
window.location.href = `#`;
}
}}
>
{btnName}
</button>
);
};
setUpButton.propTypes = {
modalId: PropTypes.string.isRequired,
otherModalId: PropTypes.string.isRequired,
btnName: PropTypes.string.isRequired,
};
const userDetailsConfig = {
twitter_username: {
hostUrl: 'https://twitter.com/',
srcImage: twitterImage,
imageAltText: 'twitter logo',
},
github_username: {
hostUrl: 'https://github.com/',
srcImage: githubImage,
imageAltText: 'github logo',
},
website_url: {
className: 'external-link-img',
hostUrl: '',
srcImage: websiteImage,
imageAltText: 'external link icon',
},
};
const UserDetails = ({ user, activeChannelId, activeChannel }) => {
const channelId = activeChannelId;
const channel = activeChannel || {};
const socialIcons = [];
const userMeta = ['twitter_username', 'github_username', 'website_url'];
userMeta.forEach(metaProp => {
if (user[metaProp]) {
const { className, hostUrl, srcImage, imageAltText } = userDetailsConfig[
metaProp
];
socialIcons.push(
<a
href={`${hostUrl}${user[metaProp]}`}
target="_blank"
rel="noopener noreferrer"
>
<img
className={className}
src={srcImage}
style={{ width: '30px', margin: '5px 15px 15px 0px' }}
alt={imageAltText}
/>
</a>,
);
}
});
let userLocation = '';
if (user.location && user.location.length > 0) {
userLocation = (
<div>
<div className="key">location</div>
<div className="value">{user.location}</div>
</div>
);
}
let blockButton = '';
if (channel.channel_type === 'direct' && window.currentUser.id !== user.id) {
blockButton = setUpButton({
modalId: 'userdetails__blockmsg',
otherModalId: 'userdetails__reportabuse',
btnName: 'Block User',
});
}
let reportButton = '';
if (window.currentUser.id !== user.id) {
reportButton = setUpButton({
modalId: 'userdetails__reportabuse',
otherModalId: 'userdetails__blockmsg',
btnName: 'Report Abuse',
});
}
return (
<div>
<img
src={user.profile_image}
alt={`${user.username} profile`}
style={{
height: '210px',
width: '210px',
margin: ' 15px auto',
display: 'block',
borderRadius: '500px',
}}
/>
<h1 style={{ textAlign: 'center' }}>
<a href={`/${user.username}`} target="_blank" rel="noopener noreferrer">
{user.name}
</a>
</h1>
<div style={{ height: '50px', margin: 'auto', width: '96%' }}>
{socialIcons}
</div>
<div style={{ fontStyle: 'italic' }}>{user.summary}</div>
<div className="activechatchannel__activecontentuserdetails">
{userLocation}
<div className="key">joined</div>
<div className="value">{user.joined_at}</div>
</div>
<div className="userdetails__blockreport">
{blockButton}
{reportButton}
</div>
<div id="userdetails__reportabuse" style={{ display: 'none' }}>
<div className="userdetails__reportabuse">
<p>Reporting abuse will: </p>
<ul>
<li>
close this chat and prevent this user from re-opening chat with
you
</li>
<li>
give the DEV team your consent to read messages in this chat to
understand your report and take appropriate action
</li>
</ul>
<p>
Blocking is only on Connect right now and has not been implemented
across DEV yet.
</p>
<h5>Are you sure?</h5>
<a
tabIndex="0"
href="/report-abuse"
onClick={() => {
blockUser(channelId);
}}
>
Yes, Report
</a>
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
<a
role="button"
tabIndex="0"
className="no"
onClick={() => {
document.getElementById(
'userdetails__reportabuse',
).style.display = 'none';
window.location.href = `#`;
}}
onKeyUp={e => {
if (e.keyCode === 13) {
document.getElementById(
'userdetails__reportabuse',
).style.display = 'none';
window.location.href = `#`;
}
}}
>
No
</a>
</div>
</div>
<div id="userdetails__blockmsg" style={{ display: 'none' }}>
<div className="userdetails__blockmsg">
<p>Blocking on connect will: </p>
<ul>
<li>
close this chat and prevent this user from re-opening chat with
you
</li>
<li>
NOT notify the user you will block--this channel will become
inaccessible for both users
</li>
</ul>
<p>
Blocking is only on Connect right now and has not been implemented
across DEV yet. Consider reporting abuse to the DEV team if this
user is spamming or harassing elsewhere on dev.to, so we can take
further action.
</p>
<h5>Are you sure?</h5>
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
<a
role="button"
tabIndex="0"
onClick={() => {
blockUser(user.id);
window.location.href = `/connect`;
}}
onKeyUp={e => {
if (e.keyCode === 13) {
blockUser(user.id);
window.location.href = `/connect`;
}
}}
>
Yes, Block
</a>
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
<a
role="button"
tabIndex="0"
className="no"
onClick={() => {
document.getElementById('userdetails__blockmsg').style.display =
'none';
window.location.href = `#`;
}}
onKeyUp={e => {
if (e.keyCode === 13) {
document.getElementById('userdetails__blockmsg').style.display =
'none';
window.location.href = `#`;
}
}}
>
No
</a>
</div>
</div>
</div>
);
};
UserDetails.propTypes = {
user: PropTypes.objectOf().isRequired,
activeChannelId: PropTypes.string.isRequired,
activeChannel: PropTypes.objectOf().isRequired,
};
export default UserDetails;

View file

@ -1,85 +0,0 @@
import { h, Component } from 'preact';
import PropTypes from 'prop-types';
/**
* TODO: Instead of calling this function in render, use jsx (<SetupButton />).
*/
function SetupButton({ className, onClickCallback, dataContent, btnLabel }) {
return (
<button
type="button"
className={className}
onClick={onClickCallback}
data-content={dataContent}
>
{btnLabel}
</button>
);
}
SetupButton.propTypes = {
className: PropTypes.string.isRequired,
onClickCallback: PropTypes.func.isRequired,
dataContent: PropTypes.string.isRequired,
btnLabel: PropTypes.string.isRequired,
};
export default class View extends Component {
static propTypes = {
onAcceptInvitation: PropTypes.func.isRequired,
onDeclineInvitation: PropTypes.func.isRequired,
onViewExit: PropTypes.func.isRequired,
channels: PropTypes.arrayOf(PropTypes.object).isRequired,
};
channel = props => {
const { onAcceptInvitation, onDeclineInvitation } = this.props;
return (
<div className="chatNonChatView_contentblock">
<h2>{props.channel.channel_name}</h2>
<div>
<em>{props.channel.description}</em>
</div>
{SetupButton({
className: 'cta',
onClickCallback: onAcceptInvitation,
dataContent: props.channel.membership_id,
btnLabel: 'Accept',
})}
{SetupButton({
className: 'cta',
onClickCallback: onDeclineInvitation,
dataContent: props.channel.membership_id,
btnLabel: 'Decline',
})}
</div>
);
};
render() {
const { onViewExit, channels: channelsFromProps } = this.props;
const channels = channelsFromProps.map(channel => {
return <this.channel channel={channel} />;
});
return (
<div className="chatNonChatView">
<div className="container">
{SetupButton({
className: 'chatNonChatView_exitbutton',
onClickCallback: onViewExit,
dataContent: 'exit',
btnLabel: '×',
})}
<h1>
Channel Invitations
{' '}
<span role="img" aria-label="hugging-emoji">
🤗
</span>
</h1>
{channels}
</div>
</div>
);
}
}

View file

@ -26,7 +26,11 @@ module AssignTagModerator
if tag.mod_chat_channel_id
ChatChannel.find(tag.mod_chat_channel_id).add_users(user) if user.chat_channels.where(id: tag.mod_chat_channel_id).none?
else
channel = ChatChannel.create_with_users(([user] + User.with_role(:mod_relations_admin)).flatten.uniq, "invite_only", "##{tag.name} mods")
channel = ChatChannel.create_with_users(
users: ([user] + User.with_role(:mod_relations_admin)).flatten.uniq,
channel_type: "invite_only",
contrived_name: "##{tag.name} mods",
)
tag.update_column(:mod_chat_channel_id, channel.id)
end
end

View file

@ -81,6 +81,17 @@ class NotifyMailer < ApplicationMailer
mail(to: @user.email, subject: subject)
end
def channel_invite_email(membership, inviter)
@membership = membership
@inviter = inviter
subject = if @membership.role == "mod"
"You are invited to Channel #{@membership.chat_channel.channel_name} as moderator."
else
"You are invited to Channel #{@membership.chat_channel.channel_name} by #{@inviter.name}."
end
mail(to: @membership.user.email, subject: subject)
end
def account_deleted_email(user)
@name = user.name
subject = "dev.to - Account Deletion Confirmation"

View file

@ -17,6 +17,7 @@ class ChatChannel < ApplicationRecord
validates :channel_type, presence: true, inclusion: { in: %w[open invite_only direct] }
validates :status, presence: true, inclusion: { in: %w[active inactive blocked] }
validates :slug, uniqueness: true, presence: true
validates :description, length: { maximum: 200 }, allow_blank: true
def open?
channel_type == "open"
@ -51,34 +52,41 @@ class ChatChannel < ApplicationRecord
chat_channel_memberships.where(user_id: user.id).pluck(:last_opened_at).first
end
def self.create_with_users(users, channel_type = "direct", contrived_name = "New Channel")
raise "Invalid direct channel" if users.size != 2 && channel_type == "direct"
class << self
def create_with_users(users:, channel_type: "direct", contrived_name: "New Channel", membership_role: "member")
raise "Invalid direct channel" if users.size != 2 && channel_type == "direct"
if channel_type == "direct"
usernames = users.map(&:username).sort # .map as `users` is an array
contrived_name = "Direct chat between " + usernames.join(" and ")
slug = usernames.join("/")
else
slug = contrived_name.to_s.parameterize + "-" + rand(100_000).to_s(26)
usernames = users.map(&:username).sort
slug = channel_type == "direct" ? usernames.join("/") : contrived_name.to_s.parameterize + "-" + rand(100_000).to_s(26)
contrived_name = "Direct chat between " + usernames.join(" and ") if channel_type == "direct"
channel = find_or_create_chat_channel(channel_type, slug, contrived_name)
if channel_type == "direct"
channel.add_users(users)
else
channel.invite_users(users: users, membership_role: membership_role)
end
channel
end
channel = ChatChannel.find_by(slug: slug)
if channel
raise "Blocked channel" if channel.status == "blocked"
private
channel.status = "active"
channel.save
else
channel = create(
channel_type: channel_type,
channel_name: contrived_name,
slug: slug,
last_message_at: 1.week.ago,
status: "active",
)
channel.add_users(users)
def find_or_create_chat_channel(channel_type, slug, contrived_name)
channel = ChatChannel.find_by(slug: slug)
if channel
raise "Blocked channel" if channel.status == "blocked"
channel.status = "active"
channel.save
else
create(
channel_type: channel_type,
channel_name: contrived_name,
slug: slug,
last_message_at: 1.week.ago,
status: "active",
)
end
end
channel
end
def add_users(users)
@ -87,6 +95,26 @@ class ChatChannel < ApplicationRecord
end
end
def invite_users(users:, membership_role: "member", inviter: nil)
invitation_sent = 0
Array(users).each do |user|
existing_membership = ChatChannelMembership.find_by(user_id: user.id, chat_channel_id: id)
if existing_membership.present? && %w[active pending].exclude?(existing_membership.status)
if existing_membership.update(status: "pending", role: membership_role)
NotifyMailer.channel_invite_email(existing_membership, inviter).deliver_later
invitation_sent += 1
end
else
membership = ChatChannelMembership.create(user_id: user.id, chat_channel_id: id, role: membership_role, status: "pending")
if membership.persisted?
NotifyMailer.channel_invite_email(membership, inviter).deliver_later
invitation_sent += 1
end
end
end
invitation_sent
end
def remove_user(user)
chat_channel_memberships.where(user: user).destroy_all
end

View file

@ -1,4 +1,6 @@
class ChatChannelMembership < ApplicationRecord
attr_accessor :invitation_usernames
include Searchable
SEARCH_SERIALIZER = Search::ChatChannelMembershipSerializer
SEARCH_CLASS = Search::ChatChannelMembership
@ -8,7 +10,7 @@ class ChatChannelMembership < ApplicationRecord
validates :user_id, presence: true, uniqueness: { scope: :chat_channel_id }
validates :chat_channel_id, presence: true, uniqueness: { scope: :user_id }
validates :status, inclusion: { in: %w[active inactive pending rejected left_channel] }
validates :status, inclusion: { in: %w[active inactive pending rejected left_channel removed_from_channel] }
validates :role, inclusion: { in: %w[member mod] }
validate :permission

View file

@ -82,7 +82,7 @@ class Message < ApplicationRecord
HTML
elsif username == "all" && chat_channel.channel_type == "invite_only"
<<~HTML
<a class='comment-mentioned-user comment-mentioned-all' data-content="chat_channels/#{chat_channel.id}" href='#' target="_blank">@#{username}</a>
<a class='comment-mentioned-user comment-mentioned-all' data-content="sidecar_all" href="#">@#{username}</a>
HTML
else
mention

View file

@ -24,7 +24,7 @@ class ChatChannelPolicy < ApplicationPolicy
end
def permitted_attributes
%i[channel_name slug command]
%i[channel_name slug command description]
end
def create_chat?

View file

@ -0,0 +1,99 @@
<% if flash[:settings_notice].present? %>
<div class="notice" id="notice">
<%= flash[:settings_notice] %>
</div>
<% elsif flash[:error].present? %>
<div class="notice error-notice" id="notice" style="text-align:center;color:red">
ERROR
<%= flash[:error] %>
</div>
<% end %>
<div class="user-settings-page">
<h1><%= @channel.channel_name %></h1>
<% if @membership.role == "mod" %>
<h3>You are a channel mod</h3>
<% end %>
<p>
<%= @channel.description %>
</p>
<div class="settings-form">
<div class="settings-form-userlist">
<h3>Members</h3>
<% @channel.active_memberships.includes(:user).each do |active_membership| %>
<div class="settings-form-userlistrow" >
<a href="<%= active_membership.user.path %>"><img src="<%= ProfileImage.new(active_membership.user).get(width: 90) %>" /><%= active_membership.user.name %></a>
<% if @membership.role == "mod" && active_membership.role != "mod" %>
<%= form_tag "/chat_channel_memberships/remove_membership", class: "inline-button-form-hidden" do %>
<%= hidden_field_tag(:chat_channel_id, @channel.id) %>
<%= hidden_field_tag(:membership_id, active_membership.id) %>
<%= submit_tag "×" %>
<% end %>
<% end %>
</div>
<% end %>
</div>
<% if @membership.role == "mod" %>
<div class="settings-form-userlist">
<h3>Pending Invitations</h3>
<% @channel.pending_memberships.includes(:user).each do |pending_membership| %>
<div class="settings-form-userlistrow" >
<a href="<%= pending_membership.user.path %>"><img src="<%= ProfileImage.new(pending_membership.user).get(width: 90) %>" /><%= pending_membership.user.name %></a>
<%= form_tag "/chat_channel_memberships/remove_membership", class: "inline-button-form" do %>
<%= hidden_field_tag(:status, "pending") %>
<%= hidden_field_tag(:chat_channel_id, @channel.id) %>
<%= hidden_field_tag(:membership_id, pending_membership.id) %>
<%= submit_tag "×" %>
<% end %>
</div>
<% end %>
</div>
<%= form_for ChatChannelMembership.new do |f| %>
<div class="field">
<%= f.hidden_field :chat_channel_id, value: @channel.id %>
<%= f.label "Usernames to Invite" %>
<%= f.text_field :invitation_usernames, placeholder: "Comma separated" %>
</div>
<div class="field">
<%= f.submit "SUBMIT", class: "cta" %>
</div>
<% end %>
<% end %>
<% if @membership.role == "mod" %>
<hr class="settings-hr"/>
<h1>Channel Settings</h1>
<h3>Notifications</h3>
<%= form_for(@channel) do |f| %>
<div class="field">
<%= f.label :description %>
<%= f.text_area :description %>
</div>
<div class="field">
<%= f.submit "SUBMIT", class: "cta" %>
</div>
<% end %>
<% end %>
<hr class="settings-hr"/>
<h1>Personal Settings</h1>
<h3>Notifications</h3>
<%= form_for(@membership) do |f| %>
<div class="field">
<%= f.label :show_global_badge_notification, "Receive Notifications for New Messages" %>
<%= f.check_box :show_global_badge_notification %>
</div>
<div class="field">
<%= f.submit "SUBMIT", class: "cta" %>
</div>
<% end %>
<hr class="settings-hr"/>
<% if @membership.role == "mod" %>
<p>Questions about Connect Channel moderation? Contact <a href="mailto:yo@dev.to">yo@dev.to</a></p>
<% else %>
<h3>Danger Zone</h3>
<%= form_for(@membership, html: { method: :delete, onsubmit: "return confirm('Are you absolutely sure you want to leave this channel? This action is permanent.');" } ) do |f| %>
<div class="field">
<%= f.submit "LEAVE CHANNEL", class: "cta cta-danger" %>
</div>
<% end %>
<% end %>
</div>
</div>

View file

@ -0,0 +1,30 @@
<% if flash[:settings_notice] %>
<div class="notice" id="notice">
<%= flash[:settings_notice] %>
</div>
<% end %>
<div class="user-settings-page">
<% if @pending_invites.any? %>
<h1>Pending Invitations</h1>
<h3>You may leave a group channel at any point in the future.</h3>
<% @pending_invites.each do |membership| %>
<div>
<h2><%= membership.chat_channel.channel_name %></h2>
<p><%= membership.chat_channel.description %></p>
<%= form_for(membership) do |f| %>
<%= f.hidden_field :user_action, value: "accept" %>
<%= f.submit "Accept", class: "cta" %>
<% end %>
<%= form_for(membership) do |f| %>
<%= f.hidden_field :user_action, value: "decline" %>
<%= f.submit "Decline", class: "cta cta-danger" %>
<% end %>
</div>
<hr class="settings-hr"/>
<% end %>
<% else %>
<p>
<em>You have no pending invitations</em>
</p>
<% end %>
</div>

View file

@ -0,0 +1,45 @@
<table style="width:100%;max-width:700px;margin:auto;font-size:18px;">
<tbody>
<tr>
<td style="padding:4%;">
<% if @membership.role == "mod" %>
<h2 style="font-size: 25px; text-align: center;">
You have been invited to be a Moderator of a connect group on <%= ApplicationConfig["COMMUNITY_NAME"] %>.
</h2>
<% else %>
<h2 style="font-size: 25px; text-align: center;">
<%= link_to(@inviter.name, ApplicationController.helpers.user_url(@inviter)) %> sent you an invitation to join a connect group on <%= ApplicationConfig["COMMUNITY_NAME"] %>.
</h2>
<% end %>
<table>
<tbody>
<tr>
<td style="color:#c3c3c3;font-size:14px;padding-bottom:8px;">
<br>
</td>
</tr>
<tr>
<td style="width: 700px; padding: 20px 10px 30px; border-top-left-radius: 3px; border-top-right-radius: 3px; border-bottom-right-radius: 3px; border-bottom-left-radius: 3px; font-size: 19px; background-color: rgb(244, 244, 244); text-align: center; background-position: initial initial; background-repeat: initial initial;">
<div style="text-align: center;">
<h1><%= @membership.chat_channel.channel_name %></h1>
</div>
<br>
<% if @membership.role == "mod" %>
<img src="https://media.giphy.com/media/VGG8UY1nEl66Y/giphy.gif" style="width: 300px;">
<% else %>
<img src="https://media.giphy.com/media/kHs1lBhZWaK5rj7lt3/giphy.gif" style="width: 300px;">
<% end %>
<br>
<br>
<div>
<p style="background:#3c7dc1;color:white;padding:5px 14px;border-radius:3px;text-decoration:none; margin-top:4px;"><%= link_to("Check Invitation", ApplicationController.helpers.app_url("connect?ref=group_invite")) %> </p>
</div>
</td>
</tr>
</tbody>
</table>
<br>
</td>
</tr>
</tbody>
</table>

View file

@ -0,0 +1,3 @@
You are invited to the <%= ApplicationConfig["COMMUNITY_NAME"] %> Connect channel <%= @membership.chat_channel.channel_name %>
View Invitation: <%= ApplicationController.helpers.app_url('connect') %>

View file

@ -9,7 +9,7 @@ module Follows
find_by(id: follow_id, follower_type: "User", followable_type: "User")
return unless follow&.followable&.following?(follow.follower)
ChatChannel.create_with_users([follow.followable, follow.follower])
ChatChannel.create_with_users(users: [follow.followable, follow.follower])
end
end
end

View file

@ -151,7 +151,7 @@ Rails.application.routes.draw do
resources :messages, only: [:create]
resources :chat_channels, only: %i[index show create update]
resources :chat_channel_memberships, only: %i[create update destroy]
resources :chat_channel_memberships, only: %i[index create edit update destroy]
resources :articles, only: %i[update create destroy]
resources :article_mutes, only: %i[update]
resources :comments, only: %i[create update destroy] do
@ -251,6 +251,7 @@ Rails.application.routes.draw do
get "/chat_channels/:id/channel_info", to: "chat_channels#channel_info", as: :chat_channel_info
post "/chat_channels/create_chat" => "chat_channels#create_chat"
post "/chat_channels/block_chat" => "chat_channels#block_chat"
post "/chat_channel_memberships/remove_membership" => "chat_channel_memberships#remove_membership"
delete "/messages/:id" => "messages#destroy"
patch "/messages/:id" => "messages#update"
get "/live/:username" => "twitch_live_streams#show"

View file

@ -294,7 +294,7 @@ Rails.logger.info "#{counter}. Creating Chat Channels and Messages"
)
end
direct_channel = ChatChannel.create_with_users(User.last(2), "direct")
direct_channel = ChatChannel.create_with_users(users: User.last(2), channel_type: "direct")
Message.create!(
chat_channel: direct_channel,
user: User.last,

View file

@ -121,7 +121,6 @@
"babel-preset-preact": "^2.0.0",
"chart.js": "^2.9.3",
"clipboard-polyfill": "^2.8.6",
"codemirror": "^5.52.2",
"core-js": "3",
"file-loader": "^6.0.0",
"focus-visible": "^5.0.2",

View file

@ -261,7 +261,7 @@ RSpec.describe NotifyMailer, type: :mailer do
end
describe "#new_message_email" do
let(:direct_channel) { ChatChannel.create_with_users([user, user2], "direct") }
let(:direct_channel) { ChatChannel.create_with_users(users: [user, user2], channel_type: "direct") }
let(:direct_message) { create(:message, user: user, chat_channel: direct_channel) }
let(:email) { described_class.new_message_email(direct_message) }
@ -407,4 +407,28 @@ RSpec.describe NotifyMailer, type: :mailer do
expect(email.html_part.body).to include(CGI.escape("utm_campaign=trusted_role_email"))
end
end
describe "#channel_invite_email" do
let(:moderator_membership) { create(:chat_channel_membership, user_id: user2.id, role: "mod") }
let(:regular_membership) { create(:chat_channel_membership, user_id: user2.id, role: "member") }
let(:moderator_email) { described_class.channel_invite_email(moderator_membership, nil) }
let(:member_email) { described_class.channel_invite_email(regular_membership, user) }
it "renders proper subject" do
expect(moderator_email.subject).to eq("You are invited to Channel #{moderator_membership.chat_channel.channel_name} as moderator.")
expect(member_email.subject).to eq("You are invited to Channel #{regular_membership.chat_channel.channel_name} by #{user.name}.")
end
it "renders proper sender" do
expect(moderator_email.from).to eq([SiteConfig.default_site_email])
expect(moderator_email["from"].value).to eq("DEV Community <#{SiteConfig.default_site_email}>")
expect(member_email.from).to eq([SiteConfig.default_site_email])
expect(member_email["from"].value).to eq("DEV Community <#{SiteConfig.default_site_email}>")
end
it "renders proper receiver" do
expect(moderator_email.to).to eq([user2.email])
expect(member_email.to).to eq([user2.email])
end
end
end

View file

@ -24,14 +24,14 @@ RSpec.describe ChatChannel, type: :model do
describe "#create_with_users" do
it "creates channel with users" do
chat_channel = described_class.create_with_users(users)
chat_channel = described_class.create_with_users(users: users)
expect(chat_channel.users.size).to eq(users.size)
expect(chat_channel.has_member?(users.first)).to be(true)
expect(chat_channel.has_member?(users.last)).to be(true)
end
it "lists active memberships" do
chat_channel = described_class.create_with_users(users)
chat_channel = described_class.create_with_users(users: users)
expect(chat_channel.active_users.size).to eq(users.size)
expect(chat_channel.channel_users.size).to eq(users.size)
end
@ -39,7 +39,7 @@ RSpec.describe ChatChannel, type: :model do
describe "#active_users" do
it "decreases active users if one leaves" do
chat_channel = described_class.create_with_users(users)
chat_channel = described_class.create_with_users(users: users)
expect(chat_channel.active_users.size).to eq(users.size)
expect(chat_channel.channel_users.size).to eq(users.size)
ChatChannelMembership.last.update(status: "left_channel")

View file

@ -10,27 +10,166 @@ RSpec.describe "ChatChannelMemberships", type: :request do
chat_channel.add_users([user])
end
describe "POST /chat_channel_memberships" do
it "creates chat channel invitation" do
user.add_role(:super_admin)
mems_num = ChatChannelMembership.all.size
post "/chat_channel_memberships", params: {
chat_channel_membership: {
user_id: second_user.id, chat_channel_id: chat_channel.id
}
}
expect(ChatChannelMembership.all.size).to eq(mems_num + 1)
expect(ChatChannelMembership.last.status).to eq("pending")
end
it "denies chat channel invitation to non-authorized user" do
expect do
describe "GET /chat_channel_memberships" do
context "when pending invitations exists" do
before do
user.add_role(:super_admin)
post "/chat_channel_memberships", params: {
chat_channel_membership: {
user_id: second_user.id, chat_channel_id: chat_channel.id
invitation_usernames: second_user.username.to_s,
chat_channel_id: chat_channel.id
}
}
end.to raise_error(Pundit::NotAuthorizedError)
end
it "shows chat_channel_memberships list pending invitation" do
sign_in second_user
get "/chat_channel_memberships"
expect(response.body).to include "Pending Invitations"
expect(response.body).to include chat_channel.channel_name.to_s
end
end
context "when no pending invitation" do
it "shows chat_channel_memberships list pending invitation" do
sign_in second_user
get "/chat_channel_memberships"
expect(response.body).to include "You have no pending invitations"
end
end
end
describe "GET /chat_channel_memberships/find_by_chat_channel_id" do
context "when user is logged in" do
before do
chat_channel.add_users([second_user])
end
it "returns chat channel membership details" do
sign_in second_user
get "/chat_channel_memberships/find_by_chat_channel_id", params: { chat_channel_id: chat_channel.id }
expected_keys = %w[id status chat_channel_id last_opened_at channel_text
channel_last_message_at channel_status channel_username
channel_type channel_name channel_image
channel_modified_slug channel_messages_count]
expect(response.parsed_body.keys).to(match_array(expected_keys))
end
end
context "when user is not logged in" do
it "renders not_found" do
expect do
get "/chat_channel_memberships/find_by_chat_channel_id", params: {}
end.to raise_error(ActiveRecord::RecordNotFound)
end
end
end
describe "GET /chat_channel_memberships/:id/edit" do
before do
chat_channel.add_users([second_user])
end
let(:chat_channel_membership) { chat_channel.chat_channel_memberships.where(user_id: second_user.id).first }
context "when user is not logged in" do
it "raise Pundit::NotAuthorizedError" do
expect do
get "/chat_channel_memberships/#{chat_channel_membership.id}/edit"
end.to raise_error(Pundit::NotAuthorizedError)
end
end
context "when user is logged in and channel id is wrong" do
it "raise ActiveRecord::RecordNotFound" do
sign_in second_user
expect do
get "/chat_channel_memberships/ERW/edit"
end.to raise_error(ActiveRecord::RecordNotFound)
end
end
context "when user is channel member" do
it "allows user to view channel members" do
sign_in second_user
get "/chat_channel_memberships/#{chat_channel_membership.id}/edit"
expect(response.body).to include("Members")
expect(response.body).to include(user.username.to_s)
expect(response.body).to include(second_user.username.to_s)
expect(response.body).not_to include("Pending Invitations")
end
end
context "when user is channel moderator" do
it "allows user to view channel members" do
sign_in second_user
chat_channel_membership.update(role: "mod")
get "/chat_channel_memberships/#{chat_channel_membership.id}/edit"
expect(response.body).to include("Members")
expect(response.body).to include(user.username.to_s)
expect(response.body).to include(second_user.username.to_s)
expect(response.body).to include("Pending Invitations")
expect(response.body).to include("You are a channel mod")
end
end
end
describe "POST /chat_channel_memberships" do
context "when user is super admin" do
it "creates chat channel invitation" do
user.add_role(:super_admin)
expect do
post "/chat_channel_memberships", params: {
chat_channel_membership: {
invitation_usernames: second_user.username.to_s,
chat_channel_id: chat_channel.id
}
}
end.to change { ChatChannelMembership.all.size }.by(1)
expect(ChatChannelMembership.last.status).to eq("pending")
end
end
context "when user is channel moderator, and invited user was a member of channel, and than left channel" do
it "creates chat channel invitation" do
chat_channel.chat_channel_memberships.where(user_id: user.id).update(role: "mod")
ChatChannelMembership.create(chat_channel_id: chat_channel.id, user_id: second_user.id, status: "left_channel")
post "/chat_channel_memberships", params: {
chat_channel_membership: {
invitation_usernames: second_user.username.to_s,
chat_channel_id: chat_channel.id
}
}
expect(ChatChannelMembership.last.status).to eq("pending")
end
end
context "when user is channel moderator, invited user was not a member of channel" do
it "creates chat channel invitation" do
chat_channel.chat_channel_memberships.where(user_id: user.id).update(role: "mod")
chat_channel_members_count = ChatChannelMembership.all.size
post "/chat_channel_memberships", params: {
chat_channel_membership: {
invitation_usernames: second_user.username.to_s,
chat_channel_id: chat_channel.id
}
}
expect(ChatChannelMembership.all.size).to eq(chat_channel_members_count + 1)
expect(ChatChannelMembership.last.status).to eq("pending")
end
end
context "when user is not authorized to add channel membership" do
it "raise Pundit::NotAuthorizedError" do
expect do
post "/chat_channel_memberships", params: {
chat_channel_membership: {
invitation_usernames: second_user.username.to_s,
chat_channel_id: chat_channel.id
}
}
end.to raise_error(Pundit::NotAuthorizedError)
end
end
end
@ -38,62 +177,130 @@ RSpec.describe "ChatChannelMemberships", type: :request do
before do
user.add_role(:super_admin)
post "/chat_channel_memberships", params: {
chat_channel_membership: { user_id: second_user.id, chat_channel_id: chat_channel.id }
}
end
it "accepts chat channel invitation" do
membership = ChatChannelMembership.last
sign_in second_user
put "/chat_channel_memberships/#{membership.id}", params: {
chat_channel_membership: {
user_action: "accept"
invitation_usernames: second_user.username.to_s,
chat_channel_id: chat_channel.id
}
}
expect(ChatChannelMembership.find(membership.id).status).to eq("active")
end
it "rejects chat channel invitation" do
membership = ChatChannelMembership.last
sign_in second_user
put "/chat_channel_memberships/#{membership.id}", params: {
chat_channel_membership: { user_action: "reject" }
}
expect(ChatChannelMembership.find(membership.id).status).to eq("rejected")
end
it "disallows non-logged-user" do
membership = ChatChannelMembership.last
expect do
context "when second user accept invitation" do
it "sets chat channl membership status to rejected" do
membership = ChatChannelMembership.last
sign_in second_user
put "/chat_channel_memberships/#{membership.id}", params: {
chat_channel_membership: { user_action: "accept" }
chat_channel_membership: {
user_action: "accept"
}
}
expect(ChatChannelMembership.find(membership.id).status).to eq("active")
end.to raise_error(Pundit::NotAuthorizedError)
expect(response).to(redirect_to(chat_channel_memberships_path))
end
end
context "when second user rejects invitation" do
it "sets chat channl membership status to rejected" do
membership = ChatChannelMembership.last
sign_in second_user
put "/chat_channel_memberships/#{membership.id}", params: {
chat_channel_membership: {
user_action: "reject"
}
}
expect(ChatChannelMembership.find(membership.id).status).to eq("rejected")
end
end
context "when user not logged in" do
it "raise Pundit::NotAuthorizedError" do
membership = ChatChannelMembership.last
expect do
put "/chat_channel_memberships/#{membership.id}", params: {
chat_channel_membership: { user_action: "accept" }
}
end.to raise_error(Pundit::NotAuthorizedError)
end
end
context "when user is unauthorized" do
it "raise Pundit::NotAuthorizedError" do
membership = ChatChannelMembership.last
sign_in user
expect do
put "/chat_channel_memberships/#{membership.id}", params: {
chat_channel_membership: { user_action: "accept" }
}
end.to raise_error(Pundit::NotAuthorizedError)
end
end
end
describe "DELETE /chat_channel_memberships/:id" do
before do
user.add_role(:super_admin)
post "/chat_channel_memberships", params: {
chat_channel_membership: { user_id: second_user.id, chat_channel_id: chat_channel.id }
}
context "when user is logged in" do
it "leaves chat channel" do
chat_channel.add_users([second_user])
membership = ChatChannelMembership.last
sign_in second_user
delete "/chat_channel_memberships/#{membership.id}", params: {}
expect(ChatChannelMembership.find(membership.id).status).to eq("left_channel")
expect(response).to(redirect_to(chat_channel_memberships_path))
end
end
it "leaves chat channel" do
membership = ChatChannelMembership.last
sign_in second_user
delete "/chat_channel_memberships/#{membership.chat_channel.id}", params: {}
expect(ChatChannelMembership.find(membership.id).status).to eq("left_channel")
context "when user is not logged in" do
it "raise Pundit::NotAuthorizedError" do
chat_channel.add_users([second_user])
membership = ChatChannelMembership.last
expect do
delete "/chat_channel_memberships/#{membership.id}", params: {}
end.to(raise_error(Pundit::NotAuthorizedError))
end
end
end
describe "GET /chat_channel_memberships/find_by_chat_channel_id" do
it "renders not_found" do
expect do
get "/chat_channel_memberships/find_by_chat_channel_id", params: {}
end.to raise_error(ActiveRecord::RecordNotFound)
describe "POST /chat_channel_memberships/remove_membership" do
before do
chat_channel.add_users([second_user])
end
context "when user is super admin" do
it "removes member from channel" do
user.add_role(:super_admin)
membership = chat_channel.chat_channel_memberships.where(user_id: user.id).last
removed_channel_membership = ChatChannelMembership.last
post "/chat_channel_memberships/remove_membership", params: {
chat_channel_id: chat_channel.id,
membership_id: removed_channel_membership.id
}
expect(removed_channel_membership.reload.status).to eq("removed_from_channel")
expect(response).to(redirect_to(edit_chat_channel_membership_path(membership.id)))
end
end
context "when user is moderator of channel" do
it "removes member from channel" do
membership = chat_channel.chat_channel_memberships.where(user_id: user.id).last
membership.update(role: "mod")
removed_channel_membership = ChatChannelMembership.last
post "/chat_channel_memberships/remove_membership", params: {
chat_channel_id: chat_channel.id,
membership_id: removed_channel_membership.id
}
expect(removed_channel_membership.reload.status).to eq("removed_from_channel")
expect(response).to(redirect_to(edit_chat_channel_membership_path(membership.id)))
end
end
context "when user is member of channel" do
it "raise Pundit::NotAuthorizedError" do
membership = ChatChannelMembership.last
expect do
post "/chat_channel_memberships/remove_membership", params: {
chat_channel_id: chat_channel.id,
membership_id: membership.id
}
end.to(raise_error(Pundit::NotAuthorizedError))
end
end
end
end

View file

@ -14,6 +14,7 @@ RSpec.describe "ChatChannels", type: :request do
before do
sign_in user
chat_channel.add_users([user])
chat_channel.chat_channel_memberships.update(status: "active")
end
describe "GET /connect" do
@ -136,10 +137,13 @@ RSpec.describe "ChatChannels", type: :request do
describe "PUT /chat_channels/:id" do
it "updates channel for valid user" do
user.add_role(:super_admin)
membership = chat_channel.chat_channel_memberships.where(user_id: user.id).last
membership.update(role: "mod")
put "/chat_channels/#{chat_channel.id}",
params: { chat_channel: { channel_name: "Hello Channel", slug: "hello-channelly" } },
headers: { HTTP_ACCEPT: "application/json" }
expect(ChatChannel.last.slug).to eq("hello-channelly")
expect(response).to(redirect_to(edit_chat_channel_membership_path(membership.id)))
end
it "dissallows invalid users" do
@ -153,10 +157,12 @@ RSpec.describe "ChatChannels", type: :request do
it "returns errors if channel is invalid" do
# slug should be taken
user.add_role(:super_admin)
membership = chat_channel.chat_channel_memberships.where(user_id: user.id).last
membership.update(role: "mod")
put "/chat_channels/#{chat_channel.id}",
params: { chat_channel: { channel_name: "HEy hey hoho", slug: invite_channel.slug } },
headers: { HTTP_ACCEPT: "application/json" }
expect(response.body).to include("Slug has already been taken")
expect(response).to(redirect_to(edit_chat_channel_membership_path(membership.id)))
end
end

View file

@ -0,0 +1,24 @@
require "rails_helper"
RSpec.describe "/internal/chat_channels", type: :request do
include ActiveJob::TestHelper
let(:user) { create(:user) }
let(:chat_channel) { create(:chat_channel) }
describe "POST /chat_channels" do
around { |example| perform_enqueued_jobs(&example) }
it "creates chat_channel for with users as moderator" do
user.add_role(:super_admin)
sign_in user
expect do
post "/internal/chat_channels",
params: { chat_channel: { channel_name: "Hello Channel", usernames_string: "#{user.username}" } },
headers: { HTTP_ACCEPT: "application/json" }
end.to change(ActionMailer::Base.deliveries, :length)
expect(ChatChannel.last.channel_name).to eq("Hello Channel")
expect(ChatChannel.last.pending_users).to include(user)
end
end
end

View file

@ -48,7 +48,7 @@ RSpec.describe "Internal::Users", type: :request do
badge_id: badge.id,
rewarding_context_message_markdown: "message",
)
ChatChannel.create_with_users([user2, user3], "direct")
ChatChannel.create_with_users(users: [user2, user3], channel_type: "direct")
user2.follow(user3)
user.follow(super_admin)
user3.follow(user2)

View file

@ -4,10 +4,10 @@ RSpec.describe Users::CleanupChatChannels, type: :service do
let(:user) { create(:user) }
let(:other_user) { create(:user) }
let!(:dm_channel) do
ChatChannel.create_with_users([user, other_user])
ChatChannel.create_with_users(users: [user, other_user])
end
let!(:open_channel) do
ChatChannel.create_with_users([user, other_user], "open")
ChatChannel.create_with_users(users: [user, other_user], channel_type: "open")
end
it "deletes direct chat channels" do

View file

@ -91,13 +91,13 @@ RSpec.describe Users::Delete, type: :service do
let_it_be(:other_user) { create(:user) }
it "deletes the user's private chat channels" do
chat_channel = ChatChannel.create_with_users([user, other_user])
chat_channel = ChatChannel.create_with_users(users: [user, other_user])
described_class.call(user)
expect(ChatChannel.find_by(id: chat_channel.id)).to be_nil
end
it "does not delete the user's open channels" do
chat_channel = ChatChannel.create_with_users([user, other_user], "open")
chat_channel = ChatChannel.create_with_users(users: [user, other_user], channel_type: "open")
described_class.call(user)
expect(ChatChannel.find_by(id: chat_channel.id)).not_to be_nil
end

View file

@ -12,7 +12,7 @@ RSpec.describe Moderator::BanishUserWorker, type: :worker do
create(:article, user_id: user.id)
create(:article, user_id: user.id)
create(:classified_listing, user: user)
ChatChannel.create_with_users([user, user2])
ChatChannel.create_with_users(users: [user, user2])
user.follow(user2)
described_class.new.perform(admin.id, user.id)
user.reload

View file

@ -4023,11 +4023,6 @@ code-point-at@^1.0.0:
resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"
integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=
codemirror@^5.52.2:
version "5.52.2"
resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-5.52.2.tgz#c29e1f7179f85eb0dd17c0586fa810e4838ff584"
integrity sha512-WCGCixNUck2HGvY8/ZNI1jYfxPG5cRHv0VjmWuNzbtCLz8qYA5d+je4QhSSCtCaagyeOwMi/HmmPTjBgiTm2lQ==
collect-v8-coverage@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/collect-v8-coverage/-/collect-v8-coverage-1.0.0.tgz#150ee634ac3650b71d9c985eb7f608942334feb1"