Skip to content

Commit c1c1165

Browse files
feature (plg_widget_chat): add a chat widget
1 parent f469d90 commit c1c1165

File tree

7 files changed

+380
-0
lines changed

7 files changed

+380
-0
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
diff --git a/public/assets/components/sidebar.js b/public/assets/components/sidebar.js
2+
index 268aa4eb..e7b93142 100644
3+
--- a/public/assets/components/sidebar.js
4+
+++ b/public/assets/components/sidebar.js
5+
@@ -52,1 +52,5 @@ export default async function ctrlSidebar(render, {}) {
6+
7+
+ qs($sidebar, ".component_sidebar > div").appendChild(createElement(`<div data-bind="chat"></div>`));
8+
+ import(location.origin + "/plg_handler_chat/sidebar_chat.js")
9+
+ .then((module) => module.default(createRender(qs($sidebar, `[data-bind="chat"]`)), { path }))
10+
+ .catch((err) => console.log(err));
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { createElement, nop } from "../lib/skeleton/index.js";
2+
import { qs, safe } from "../lib/dom.js";
3+
import rxjs, { effect, applyMutation, preventDefault } from "../../lib/rx.js";
4+
import ajax from "../../lib/ajax.js";
5+
import t from "../locales/index.js";
6+
import { createModal, MODAL_RIGHT_BUTTON } from "../components/modal.js";
7+
8+
export default async function(render, { path }) {
9+
const $page = createElement(`
10+
<div>
11+
<h3 class="no-select">
12+
<img style="position:relative;top:1px;" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIHN0eWxlPSJzdHJva2U6IzU3NTk1YSIgZD0iTTIxIDE1YTIgMiAwIDAgMS0yIDJIN2wtNCA0VjVhMiAyIDAgMCAxIDItMmgxNGEyIDIgMCAwIDEgMiAyeiIvPjwvc3ZnPg==" alt="chat">
13+
<span data-bind="title">${t("Chat")}</span>
14+
</h3>
15+
<ul data-bind="messages"></ul>
16+
<style>${CSS}</style>
17+
</div>
18+
`);
19+
render($page);
20+
21+
const refresh$ = getMessages(path).pipe(
22+
rxjs.map((messages) => {
23+
const $messages = document.createDocumentFragment();
24+
for (const message of messages) {
25+
$messages.appendChild(renderMessage(message, {
26+
onClick: () => onMessageClick({ path: message.path }),
27+
}));
28+
}
29+
return $messages;
30+
}),
31+
applyMutation(qs($page, `[data-bind="messages"]`), "replaceChildren"),
32+
);
33+
34+
effect(refresh$.pipe(
35+
));
36+
37+
effect(rxjs.of(createElement(`<form><input type="text" name="message" placeholder="${t("Chat")}" /></form>`)).pipe(
38+
applyMutation(qs($page, `[data-bind="title"]`), "replaceChildren"),
39+
rxjs.mergeMap(() => rxjs.fromEvent(qs($page, "form"), "submit")),
40+
preventDefault(),
41+
rxjs.mergeMap((e) => {
42+
const message = new FormData(e.target).get("message");
43+
qs($page, "input").value = "..."
44+
return createMessage({ message, path });
45+
}),
46+
rxjs.tap(() => qs($page, "input").value = ""),
47+
rxjs.mergeMap(() => refresh$),
48+
));
49+
}
50+
51+
function renderMessage(obj, { onClick = nop, sidebar = true }) {
52+
const $message = createElement(`
53+
<li title="${safe(obj.message)}">
54+
<a data-link draggable="false">
55+
<div class="${sidebar ? "ellipsis" : "" }">
56+
<span class="message-author">${obj.author}:</span>
57+
<span class="message-content">${obj.message}</span>
58+
</div>
59+
</a>
60+
</li>
61+
`);
62+
63+
effect(rxjs.fromEvent($message, "click").pipe(
64+
rxjs.tap(() => onClick()),
65+
));
66+
67+
effect(rxjs.of(null).pipe(
68+
rxjs.filter(() => document.body.classList.contains("touch-no")),
69+
rxjs.tap(() => $message.onmouseenter = () => {
70+
const $things = document.querySelectorAll(".component_thing");
71+
$things.forEach(($thing) => {
72+
const thingpath = $thing.getAttribute("data-path");
73+
if (obj.path.indexOf(thingpath) !== -1) $thing.classList.add("hover");
74+
});
75+
$message.onmouseleave = () => $things.forEach(($thing) => $thing.classList.remove("hover"));
76+
}),
77+
));
78+
79+
return $message;
80+
}
81+
82+
function onMessageClick({ path }) {
83+
const modalHTML = `
84+
<div data-bind="thread">
85+
<component-icon name="loading"></component-icon>
86+
</div>
87+
`;
88+
const $modal = createElement(modalHTML);
89+
createModal({})($modal);
90+
91+
effect(getMessages(path).pipe(
92+
rxjs.map((messages) => {
93+
const $page = createElement(`
94+
<div>
95+
<form>
96+
<input name="message" type="text" placeholder="Message">
97+
</form>
98+
<ul data-bind="messages" class="${messages.length > 7 ? "scroll-y" : ""}"></ul>
99+
</div>
100+
`);
101+
const $messages = document.createDocumentFragment();
102+
for (const message of messages) {
103+
$messages.appendChild(renderMessage(message, { sidebar: false }));
104+
}
105+
qs($page, `[data-bind="messages"]`).appendChild($messages);
106+
return $page;
107+
}),
108+
applyMutation($modal, "replaceChildren"),
109+
rxjs.mergeMap(($modal) => rxjs.fromEvent(qs($modal, "form"), "submit")),
110+
preventDefault(),
111+
rxjs.mergeMap((e) => {
112+
$modal.replaceChildren(createElement(modalHTML));
113+
return createMessage({
114+
message: new FormData(e.target).get("message"),
115+
path,
116+
});
117+
}),
118+
rxjs.first(),
119+
));
120+
}
121+
122+
function getMessages(path = "/") {
123+
return ajax({ url: "api/messages?path="+path, responseType: "json" }).pipe(
124+
rxjs.map(({ responseJSON }) => responseJSON.results.reverse()),
125+
);
126+
}
127+
128+
function createMessage(body) {
129+
return ajax({
130+
url: "api/messages",
131+
method: "POST",
132+
body,
133+
});
134+
}
135+
136+
const CSS = `
137+
/* MESSAGES */
138+
[data-bind="messages"] {
139+
font-size: 0.9rem;
140+
}
141+
[data-bind="messages"] .message-author {
142+
font-weight: 500;
143+
opacity: 0.5;
144+
}
145+
146+
/* SIDEBAR */
147+
.component_filemanager_shell .component_sidebar [data-bind="chat"] a {
148+
cursor: pointer;
149+
}
150+
151+
/* MODAL */
152+
component-modal [data-bind="thread"] component-icon[name="loading"] {
153+
display: block;
154+
height: 30px;
155+
text-align: center;
156+
}
157+
component-modal [data-bind="thread"] form input {
158+
font-size: 1rem;
159+
border: 2px solid var(--border);
160+
border-radius: 5px;
161+
padding: 5px 10px;
162+
color: rgba(0, 0, 0, 0.75);
163+
width: 100%;
164+
display: block;
165+
box-sizing: border-box;
166+
}
167+
component-modal [data-bind="thread"] [data-bind="messages"] {
168+
list-style-type: none;
169+
margin: 10px 0 0 0;
170+
padding: 0;
171+
max-height: 200px;
172+
}
173+
component-modal [data-bind="thread"] [data-bind="messages"] > li {
174+
line-height: 1rem;
175+
margin: 5px 5px;
176+
text-align: justify;
177+
text-transform: capitalize;
178+
}
179+
`;
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package plg_widget_chat
2+
3+
import (
4+
"os"
5+
"database/sql"
6+
7+
. "github.com/mickael-kerjean/filestash/server/common"
8+
)
9+
10+
var db *sql.DB
11+
12+
func init() {
13+
Hooks.Register.Onload(func() {
14+
if err := initDB(); err != nil {
15+
Log.Error("plg_handler_chat::db err=cannot_init msg=%s", err.Error())
16+
os.Exit(1)
17+
}
18+
})
19+
}
20+
21+
func initDB () error {
22+
var err error
23+
db, err = sql.Open("sqlite3", GetAbsolutePath(DB_PATH, "chat.db"))
24+
if err != nil {
25+
return err
26+
}
27+
_, err = db.Exec(`
28+
CREATE TABLE IF NOT EXISTS messages (
29+
id TEXT PRIMARY KEY,
30+
path TEXT NOT NULL,
31+
author TEXT NOT NULL,
32+
message TEXT NOT NULL,
33+
creation_date INTEGER NOT NULL
34+
);
35+
CREATE INDEX IF NOT EXISTS idx_messages ON messages(path, creation_date);
36+
`)
37+
return err
38+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package plg_widget_chat
2+
3+
import (
4+
"net/http"
5+
"strings"
6+
"time"
7+
"database/sql"
8+
9+
. "github.com/mickael-kerjean/filestash/server/common"
10+
)
11+
12+
func list(ctx *App, w http.ResponseWriter, r *http.Request) {
13+
path := strings.TrimSpace(r.URL.Query().Get("path"))
14+
var (
15+
rows *sql.Rows
16+
err error
17+
)
18+
if path == "" {
19+
rows, err = db.QueryContext(r.Context(), `
20+
SELECT id, path, author, message, creation_date
21+
FROM messages
22+
ORDER BY creation_date DESC
23+
LIMIT 50
24+
`)
25+
} else {
26+
rows, err = db.QueryContext(r.Context(), `
27+
SELECT id, path, author, message, creation_date
28+
FROM messages
29+
WHERE path GLOB ?
30+
ORDER BY creation_date ASC
31+
`, globAll(path))
32+
}
33+
if err != nil {
34+
SendErrorResult(w, err)
35+
return
36+
}
37+
defer rows.Close()
38+
39+
out := []Message{}
40+
for rows.Next() {
41+
var m Message
42+
if err := rows.Scan(
43+
&m.ID,
44+
&m.Path,
45+
&m.Author,
46+
&m.Message,
47+
&m.CreatedAt,
48+
); err != nil {
49+
SendErrorResult(w, err)
50+
return
51+
}
52+
out = append(out, m)
53+
}
54+
SendSuccessResults(w, out)
55+
}
56+
57+
func create(ctx *App, w http.ResponseWriter, r *http.Request) {
58+
path, ok := ctx.Body["path"].(string)
59+
if !ok {
60+
SendErrorResult(w, NewError("Invalid parameters", 400))
61+
return
62+
}
63+
msg, ok := ctx.Body["message"].(string)
64+
if !ok {
65+
SendErrorResult(w, NewError("Invalid parameters", 400))
66+
return
67+
}
68+
m := Message{
69+
ID: newID(),
70+
Path: path,
71+
Author: getUser(ctx.Session),
72+
Message: msg,
73+
CreatedAt: time.Now().Unix(),
74+
}
75+
_, err := db.ExecContext(ctx.Context, `
76+
INSERT INTO messages(id, path, author, message, creation_date)
77+
VALUES(?,?,?,?,?)
78+
`, m.ID, m.Path, m.Author, m.Message, m.CreatedAt)
79+
if err != nil {
80+
SendErrorResult(w, err)
81+
return
82+
}
83+
SendSuccessResult(w, nil)
84+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package plg_widget_chat
2+
3+
import (
4+
_ "embed"
5+
"net/http"
6+
7+
. "github.com/mickael-kerjean/filestash/server/common"
8+
. "github.com/mickael-kerjean/filestash/server/middleware"
9+
10+
"github.com/gorilla/mux"
11+
)
12+
13+
//go:embed assets/sidebar_chat.js
14+
var CTRLJS []byte
15+
16+
//go:embed assets/sidebar.diff
17+
var PATCH []byte
18+
19+
func init() {
20+
Hooks.Register.HttpEndpoint(func(r *mux.Router) error {
21+
r.HandleFunc("/api/messages", NewMiddlewareChain(create, []Middleware{ApiHeaders, SecureHeaders, SessionStart, BodyParser})).Methods("POST")
22+
r.HandleFunc("/api/messages", NewMiddlewareChain(list, []Middleware{ApiHeaders, SecureHeaders, SessionStart})).Methods("GET")
23+
24+
r.HandleFunc(WithBase("/plg_handler_chat/sidebar_chat.js"), func(res http.ResponseWriter, req *http.Request) {
25+
http.Redirect(res, req, WithBase("/assets/"+BUILD_REF+"/components/sidebar_chat.js"), http.StatusSeeOther)
26+
})
27+
r.HandleFunc(WithBase("/assets/"+BUILD_REF+"/components/sidebar_chat.js"), func(res http.ResponseWriter, req *http.Request) {
28+
res.Header().Set("Content-Type", "application/javascript")
29+
res.Write(CTRLJS)
30+
}).Methods("GET")
31+
return nil
32+
})
33+
34+
Hooks.Register.StaticPatch(PATCH)
35+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package plg_widget_chat
2+
3+
type Message struct {
4+
ID string `json:"id"`
5+
Path string `json:"path"`
6+
Author string `json:"author"`
7+
Message string `json:"message"`
8+
CreatedAt int64 `json:"created_at"`
9+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package plg_widget_chat
2+
3+
import (
4+
"crypto/rand"
5+
"encoding/hex"
6+
)
7+
8+
func newID() string {
9+
var b [16]byte
10+
_, _ = rand.Read(b[:])
11+
return hex.EncodeToString(b[:])
12+
}
13+
14+
func getUser(session map[string]string) string {
15+
if session["username"] != "" {
16+
return session["username"]
17+
} else if session["user"] != "" {
18+
return session["user"]
19+
}
20+
return "unknown"
21+
}
22+
23+
func globAll(path string) string {
24+
return path + "**"
25+
}

0 commit comments

Comments
 (0)