Skip to content
This repository has been archived by the owner on Apr 22, 2023. It is now read-only.

Commit

Permalink
Create and retrieve messages through RoomChannel
Browse files Browse the repository at this point in the history
  • Loading branch information
bnhansn committed Oct 22, 2016
1 parent 1c1a394 commit 9b69879
Show file tree
Hide file tree
Showing 15 changed files with 346 additions and 9 deletions.
1 change: 1 addition & 0 deletions api/lib/sling/repo.ex
@@ -1,3 +1,4 @@
defmodule Sling.Repo do defmodule Sling.Repo do
use Ecto.Repo, otp_app: :sling use Ecto.Repo, otp_app: :sling
use Scrivener, page_size: 25
end end
5 changes: 3 additions & 2 deletions api/mix.exs
Expand Up @@ -19,7 +19,7 @@ defmodule Sling.Mixfile do
def application do def application do
[mod: {Sling, []}, [mod: {Sling, []},
applications: [:phoenix, :phoenix_pubsub, :phoenix_html, :cowboy, :logger, :gettext, applications: [:phoenix, :phoenix_pubsub, :phoenix_html, :cowboy, :logger, :gettext,
:phoenix_ecto, :postgrex, :comeonin]] :phoenix_ecto, :postgrex, :comeonin, :scrivener_ecto]]
end end


# Specifies which paths to compile per environment. # Specifies which paths to compile per environment.
Expand All @@ -40,7 +40,8 @@ defmodule Sling.Mixfile do
{:cowboy, "~> 1.0"}, {:cowboy, "~> 1.0"},
{:comeonin, "~> 2.5"}, {:comeonin, "~> 2.5"},
{:guardian, "~> 0.13.0"}, {:guardian, "~> 0.13.0"},
{:cors_plug, "~> 1.1"}] {:cors_plug, "~> 1.1"},
{:scrivener_ecto, "~> 1.0"}]
end end


# Aliases are shortcuts or tasks specific to the current project. # Aliases are shortcuts or tasks specific to the current project.
Expand Down
2 changes: 2 additions & 0 deletions api/mix.lock
Expand Up @@ -22,4 +22,6 @@
"poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [:rebar], []}, "poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [:rebar], []},
"postgrex": {:hex, :postgrex, "0.12.1", "2f8b46cb3a44dcd42f42938abedbfffe7e103ba4ce810ccbeee8dcf27ca0fb06", [:mix], [{:connection, "~> 1.0", [hex: :connection, optional: false]}, {:db_connection, "~> 1.0-rc.4", [hex: :db_connection, optional: false]}, {:decimal, "~> 1.0", [hex: :decimal, optional: false]}]}, "postgrex": {:hex, :postgrex, "0.12.1", "2f8b46cb3a44dcd42f42938abedbfffe7e103ba4ce810ccbeee8dcf27ca0fb06", [:mix], [{:connection, "~> 1.0", [hex: :connection, optional: false]}, {:db_connection, "~> 1.0-rc.4", [hex: :db_connection, optional: false]}, {:decimal, "~> 1.0", [hex: :decimal, optional: false]}]},
"ranch": {:hex, :ranch, "1.2.1", "a6fb992c10f2187b46ffd17ce398ddf8a54f691b81768f9ef5f461ea7e28c762", [:make], []}, "ranch": {:hex, :ranch, "1.2.1", "a6fb992c10f2187b46ffd17ce398ddf8a54f691b81768f9ef5f461ea7e28c762", [:make], []},
"scrivener": {:hex, :scrivener, "2.1.1", "eb52c8b7d283e8999edd6fd50d872ab870669d1f4504134841d0845af11b5ef3", [:mix], []},
"scrivener_ecto": {:hex, :scrivener_ecto, "1.0.2", "4b10a2e6c23ed8aae59731d7ae71bfd55afea6559aae61b124e6e521055b4a9c", [:mix], [{:ecto, "~> 2.0", [hex: :ecto, optional: false]}, {:postgrex, "~> 0.11.0 or ~> 0.12.0", [hex: :postgrex, optional: true]}, {:scrivener, "~> 2.0", [hex: :scrivener, optional: false]}]},
"uuid": {:hex, :uuid, "1.1.5", "96cb36d86ee82f912efea4d50464a5df606bf3f1163d6bdbb302d98474969369", [:mix], []}} "uuid": {:hex, :uuid, "1.1.5", "96cb36d86ee82f912efea4d50464a5df606bf3f1163d6bdbb302d98474969369", [:mix], []}}
30 changes: 30 additions & 0 deletions api/web/channels/room_channel.ex
Expand Up @@ -4,14 +4,44 @@ defmodule Sling.RoomChannel do
def join("rooms:" <> room_id, _params, socket) do def join("rooms:" <> room_id, _params, socket) do
room = Repo.get!(Sling.Room, room_id) room = Repo.get!(Sling.Room, room_id)


page =
Sling.Message
|> where([m], m.room_id == ^room.id)
|> order_by([desc: :inserted_at, desc: :id])
|> preload(:user)
|> Sling.Repo.paginate()

response = %{ response = %{
room: Phoenix.View.render_one(room, Sling.RoomView, "room.json"), room: Phoenix.View.render_one(room, Sling.RoomView, "room.json"),
messages: Phoenix.View.render_many(page.entries, Sling.MessageView, "message.json"),
pagination: Sling.PaginationHelpers.pagination(page)
} }


{:ok, response, assign(socket, :room, room)} {:ok, response, assign(socket, :room, room)}
end end


def handle_in("new_message", params, socket) do
changeset =
socket.assigns.room
|> build_assoc(:messages, user_id: socket.assigns.current_user.id)
|> Sling.Message.changeset(params)

case Repo.insert(changeset) do
{:ok, message} ->
broadcast_message(socket, message)
{:reply, :ok, socket}
{:error, changeset} ->
{:reply, {:error, Phoenix.View.render(Sling.ChangesetView, "error.json", changeset: changeset)}, socket}
end
end

def terminate(_reason, socket) do def terminate(_reason, socket) do
{:ok, socket} {:ok, socket}
end end

defp broadcast_message(socket, message) do
message = Repo.preload(message, :user)
rendered_message = Phoenix.View.render_one(message, Sling.MessageView, "message.json")
broadcast!(socket, "message_created", rendered_message)
end
end end
15 changes: 15 additions & 0 deletions api/web/views/message_view.ex
@@ -0,0 +1,15 @@
defmodule Sling.MessageView do
use Sling.Web, :view

def render("message.json", %{message: message}) do
%{
id: message.id,
inserted_at: message.inserted_at,
text: message.text,
user: %{
email: message.user.email,
username: message.user.username
}
}
end
end
10 changes: 10 additions & 0 deletions api/web/views/pagination_helpers.ex
@@ -0,0 +1,10 @@
defmodule Sling.PaginationHelpers do
def pagination(page) do
%{
page_number: page.page_number,
page_size: page.page_size,
total_pages: page.total_pages,
total_entries: page.total_entries
}
end
end
16 changes: 16 additions & 0 deletions web/src/actions/room.js
@@ -1,8 +1,14 @@
import { reset } from 'redux-form';

export function connectToChannel(socket, roomId) { export function connectToChannel(socket, roomId) {
return (dispatch) => { return (dispatch) => {
if (!socket) { return false; } if (!socket) { return false; }
const channel = socket.channel(`rooms:${roomId}`); const channel = socket.channel(`rooms:${roomId}`);


channel.on('message_created', (message) => {
dispatch({ type: 'MESSAGE_CREATED', message });
});

channel.join().receive('ok', (response) => { channel.join().receive('ok', (response) => {
dispatch({ type: 'ROOM_CONNECTED_TO_CHANNEL', response, channel }); dispatch({ type: 'ROOM_CONNECTED_TO_CHANNEL', response, channel });
}); });
Expand All @@ -19,3 +25,13 @@ export function leaveChannel(channel) {
dispatch({ type: 'USER_LEFT_ROOM' }); dispatch({ type: 'USER_LEFT_ROOM' });
}; };
} }

export function createMessage(channel, data) {
return dispatch => new Promise((resolve, reject) => {
channel.push('new_message', data)
.receive('ok', () => resolve(
dispatch(reset('newMessage'))
))
.receive('error', () => reject());
});
}
1 change: 0 additions & 1 deletion web/src/actions/session.js
Expand Up @@ -10,7 +10,6 @@ function connectToSocket(dispatch) {
const token = JSON.parse(localStorage.getItem('token')); const token = JSON.parse(localStorage.getItem('token'));
const socket = new Socket(`${WEBSOCKET_URL}/socket`, { const socket = new Socket(`${WEBSOCKET_URL}/socket`, {
params: { token }, params: { token },
logger: (kind, msg, data) => { console.log(`${kind}: ${msg}`, data); }
}); });
socket.connect(); socket.connect();
dispatch({ type: 'SOCKET_CONNECTED', socket }); dispatch({ type: 'SOCKET_CONNECTED', socket });
Expand Down
24 changes: 24 additions & 0 deletions web/src/components/Avatar/index.js
@@ -0,0 +1,24 @@
// @flow
import React from 'react';
import md5 from 'md5';

type Props = {
email: string,
size?: number,
style?: Object,
}

const Avatar = ({ email, size = 40, style }: Props) => {
const hash = md5(email);
const uri = `https://secure.gravatar.com/avatar/${hash}`;

return (
<img
src={uri}
alt={email}
style={{ width: `${size}px`, height: `${size}px`, borderRadius: '4px', ...style }}
/>
);
};

export default Avatar;
29 changes: 29 additions & 0 deletions web/src/components/Message/index.js
@@ -0,0 +1,29 @@
// @flow
import React from 'react';
import moment from 'moment';
import Avatar from '../Avatar';

type Props = {
message: {
text: string,
inserted_at: string,
user: {
email: string,
username: string,
},
}
}

const Message = ({ message: { text, inserted_at, user } }: Props) =>
<div style={{ display: 'flex', marginBottom: '10px' }}>
<Avatar email={user.email} style={{ marginRight: '10px' }} />
<div>
<div style={{ lineHeight: '1.2' }}>
<b style={{ marginRight: '8px', fontSize: '14px' }}>{user.username}</b>
<time style={{ fontSize: '12px', color: 'rgb(192,192,192)' }}>{moment(inserted_at).format('h:mm A')}</time>
</div>
<div>{text}</div>
</div>
</div>;

export default Message;
73 changes: 73 additions & 0 deletions web/src/components/MessageForm/index.js
@@ -0,0 +1,73 @@
// @flow
import React, { Component } from 'react';
import { Field, reduxForm } from 'redux-form';
import { css, StyleSheet } from 'aphrodite';

const styles = StyleSheet.create({
form: {
padding: '0px 10px 10px 10px',
background: '#fff',
},

input: {
borderWidth: '2px',
borderColor: 'rgb(214,214,214)',
},

button: {
color: 'rgb(80,80,80)',
background: 'rgb(214,214,214)',
borderWidth: '2px',
borderColor: 'rgb(214,214,214)',
},
});

type Props = {
onSubmit: () => void,
handleSubmit: () => void,
submitting: boolean,
}

class MessageForm extends Component {
props: Props

handleSubmit = data => this.props.onSubmit(data);

render() {
const { handleSubmit, submitting } = this.props;

return (
<form onSubmit={handleSubmit(this.handleSubmit)} className={css(styles.form)}>
<div className="input-group">
<Field
name="text"
type="text"
component="input"
className={`form-control ${css(styles.input)}`}
/>
<div className="input-group-btn">
<button
disabled={submitting}
className={`btn ${css(styles.button)}`}
>
Send
</button>
</div>
</div>
</form>
);
}
}

const validate = (values) => {
const errors = {};
if (!values.text) {
errors.text = 'Required';
}
return errors;
};

export default reduxForm({
form: 'newMessage',
validate,
})(MessageForm);
88 changes: 88 additions & 0 deletions web/src/components/MessageList/index.js
@@ -0,0 +1,88 @@
// @flow
import React, { Component } from 'react';
import moment from 'moment';
import groupBy from 'lodash/groupBy';
import mapKeys from 'lodash/mapKeys';
import { css, StyleSheet } from 'aphrodite';
import Message from '../Message';

const styles = StyleSheet.create({
container: {
flex: '1',
padding: '10px 10px 0 10px',
background: '#fff',
overflowY: 'auto',
},

dayDivider: {
position: 'relative',
margin: '1rem 0',
textAlign: 'center',
'::after': {
position: 'absolute',
top: '50%',
right: '0',
left: '0',
height: '1px',
background: 'rgb(240,240,240)',
content: '""',
},
},

dayText: {
zIndex: '1',
position: 'relative',
background: '#fff',
padding: '0 12px',
},
});

type MessageType = {
id: number,
inserted_at: string,
}

type Props = {
messages: Array<MessageType>,
}

class MessageList extends Component {
props: Props

renderMessages = messages =>
messages.map(message => <Message key={message.id} message={message} />);

renderDays() {
const { messages } = this.props;
messages.map(message => message.day = moment(message.inserted_at).format('MMMM Do')); // eslint-disable-line
const dayGroups = groupBy(messages, 'day');
const days = [];
mapKeys(dayGroups, (value, key) => {
days.push({ date: key, messages: value });
});
const today = moment().format('MMMM Do');
const yesterday = moment().subtract(1, 'days').format('MMMM Do');
return days.map(day =>
<div key={day.date}>
<div className={css(styles.dayDivider)}>
<span className={css(styles.dayText)}>
{day.date === today && 'Today'}
{day.date === yesterday && 'Yesterday'}
{![today, yesterday].includes(day.date) && day.date}
</span>
</div>
{this.renderMessages(day.messages)}
</div>
);
}

render() {
return (
<div className={css(styles.container)}>
{this.renderDays()}
</div>
);
}
}

export default MessageList;
24 changes: 24 additions & 0 deletions web/src/components/RoomNavbar/index.js
@@ -0,0 +1,24 @@
// @flow
import React from 'react';
import { css, StyleSheet } from 'aphrodite';

const styles = StyleSheet.create({
navbar: {
padding: '15px',
background: '#fff',
borderBottom: '1px solid rgb(240,240,240)',
},
});

type Props = {
room: {
name: string,
},
}

const RoomNavbar = ({ room }: Props) =>
<nav className={css(styles.navbar)}>
<div>#{room.name}</div>
</nav>;

export default RoomNavbar;

0 comments on commit 9b69879

Please sign in to comment.