diff --git a/build.sh b/build.sh
index 8ae3c98..e3f390a 100644
--- a/build.sh
+++ b/build.sh
@@ -8,6 +8,11 @@ bin2cpp --file=client/header_bottom.html --output=server/src/generated
bin2cpp --file=client/footer.html --output=server/src/generated
bin2cpp --file=client/login.html --output=server/src/generated
bin2cpp --file=client/settings.html --output=server/src/generated
+bin2cpp --file=client/friends.html --output=server/src/generated
bin2cpp --file=client/e404.html --output=server/src/generated
bin2cpp --file=client/style.css --output=server/src/generated
-bin2cpp --file=client/script.js --output=server/src/generated
+
+bin2cpp --file=client/script.js --output=server/src/generated
+bin2cpp --file=client/script_friends.js --output=server/src/generated
+
+bin2cpp --file=client/notify.opus --output=server/src/generated
diff --git a/client/friends.html b/client/friends.html
new file mode 100644
index 0000000..d83196e
--- /dev/null
+++ b/client/friends.html
@@ -0,0 +1,42 @@
+
+
+
+ friends
+
+
+
+
+
+
+
+
+
+
diff --git a/client/header_bottom.html b/client/header_bottom.html
index 9b9be3c..334ed00 100644
--- a/client/header_bottom.html
+++ b/client/header_bottom.html
@@ -7,6 +7,7 @@
diff --git a/client/login.html b/client/login.html
index b370492..4cfc7bd 100644
--- a/client/login.html
+++ b/client/login.html
@@ -14,6 +14,7 @@
diff --git a/client/notify.opus b/client/notify.opus
new file mode 100644
index 0000000..c1e1459
Binary files /dev/null and b/client/notify.opus differ
diff --git a/client/script.js b/client/script.js
index 1e1712e..3fdb9b2 100644
--- a/client/script.js
+++ b/client/script.js
@@ -104,7 +104,7 @@ document.addEventListener('DOMContentLoaded', async function() {
// update nav link / redirect regardless of whether a postbox exists
const loginLink = document.getElementById("loginLink");
if (userStatus.username === "") {
- if (window.location.pathname === "/settings") {
+ if (window.location.pathname === "/settings" || window.location.pathname === "/friends") {
window.location.href = "/login";
return;
}
diff --git a/client/script_friends.js b/client/script_friends.js
new file mode 100644
index 0000000..e7e8f2c
--- /dev/null
+++ b/client/script_friends.js
@@ -0,0 +1,240 @@
+class Message {
+ sender = 0;
+ content = "";
+
+ constructor(sender, content) {
+ this.sender = sender;
+ this.content = content;
+ }
+}
+
+// websocket connection
+/** @type {WebSocket} */
+let socket;
+
+// array of userId's
+/** @type {Array} */
+let connectedFriends = [];
+
+// map of userId's to arrays of Message objects
+/** @type {Map>} */
+let recievedMessages = new Map();
+
+// map of userId's to usernames
+/** @type {Map} */
+let usernames = new Map();
+
+// userId of the current user who is being talked to
+let currentUser = 0;
+
+// userId of the logged-in user (this client). Fetched from /me on load.
+// /me responds with headers X-Logged-In, X-Username, X-Bio, X-User-ID
+// (no body), so we read the id back out of the response headers.
+let selfId = 0;
+
+// notification sound, loaded after ws initialization
+let sound;
+
+async function fetchSelfId() {
+ try {
+ const res = await fetch("/me");
+ if (!res.ok) {
+ console.error("Failed to fetch /me:", res.status);
+ return;
+ }
+ const loggedIn = res.headers.get("X-Logged-In");
+ if (loggedIn !== "true") {
+ console.log("Not logged in");
+ return;
+ }
+ const idHeader = res.headers.get("X-User-ID");
+ selfId = parseInt(idHeader, 10);
+ } catch (err) {
+ console.error("Error fetching /me:", err);
+ }
+}
+
+/**
+ * @param {number} id
+ * @returns {string}
+ */
+async function fetchUsername(id) {
+ try {
+ const res = await fetch("/userinfo/" + id);
+ if (!res.ok) {
+ console.error("Failed to fetch /userinfo/" + id + " :", res.status);
+ return;
+ }
+ const usernameHeader = res.headers.get("X-Username");
+ usernames.set(id, usernameHeader);
+ return usernameHeader;
+ } catch (err) {
+ console.error("Error fetching /me:", err);
+ }
+}
+
+
+document.addEventListener('DOMContentLoaded', function() {
+ fetchSelfId();
+
+ if (typeof Notification !== "undefined") {
+ Notification.requestPermission();
+ }
+
+ // create a websocket
+ socket = new WebSocket("ws://" + document.location.hostname + ":" + document.location.port + "/friends/ws");
+
+ socket.onopen = function() {
+ console.log("connected to server!");
+ setInterval(() => { // ping the server every 5 seconds
+ if (socket.readyState === WebSocket.OPEN) {
+ socket.send(JSON.stringify({ type: 3, userId: 0 }));
+ }
+ }, 5000);
+ };
+
+ socket.onmessage = function(event) {
+ console.log("recieved data", event.data);
+ const data = JSON.parse(event.data);
+ switch (data.type) {
+ case "ok": {
+ console.log("Server is ok");
+ break;
+ }
+ case "error": {
+ console.error("Server sent an error:", data.content);
+ break;
+ }
+ case "message": {
+ console.log("message from", data.userId, ":", data.content);
+ // update the messages variable
+ if (recievedMessages.get(data.userId) === undefined) {
+ recievedMessages.set(data.userId, []);
+ }
+ recievedMessages.get(data.userId).push(new Message(data.userId, data.content));
+ if (currentUser === data.userId) {
+ updateMessages();
+ }
+ // send a notification to the user
+ if (document.hidden || currentUser != data.userId) {
+ if (Notification !== undefined) {
+ new Notification(usernames.get(data.userId), {body: data.content});
+ }
+ if (audio !== undefined) {
+ audio.play();
+ }
+ }
+ break;
+ }
+ case "users": {
+ console.log("updating user list");
+ connectedFriends = data.content;
+ updateOnlineUsers();
+ break;
+ }
+ }
+ }
+
+ socket.onerror = function(error) {
+ console.log(error);
+ alert("There was an error with the socket! Check console logs for details. Refresh the page to reconnect");
+ socket.close();
+ }
+
+ socket.onclose = function(event) {
+ console.log("disconnected from server");
+ }
+
+ audio = new Audio();
+ audio.preload = "auto";
+ audio.src = "/notify.opus";
+ document.body.appendChild(audio);
+
+});
+
+function updateOnlineUsers() {
+ const friendsList = document.getElementById("friends-list-links");
+ friendsList.innerHTML = "";
+
+ // Get everyone's username
+ for (const user of connectedFriends) {
+ if (usernames.get(user) === undefined) {
+ fetchUsername(user);
+ }
+ }
+
+ for (const user of connectedFriends) {
+ const newElement = document.createElement("p");
+ newElement.textContent = usernames.get(user);
+ newElement.onclick = function() {
+ openUser(user);
+ }
+ friendsList.appendChild(newElement);
+ }
+}
+
+/** @param {Message} message */
+function renderMessage(message) {
+ const messagesList = document.getElementById("friends-messages");
+ const newElement = document.createElement("p");
+ const who = message.sender === selfId ? "You" : usernames.get(message.sender);
+ newElement.textContent = who + ": " + message.content;
+ messagesList.appendChild(newElement);
+ messagesList.scrollTop = messagesList.scrollHeight;
+}
+
+function updateMessages() {
+ const messages = recievedMessages.get(currentUser);
+ if (messages === undefined || messages.length === 0) {
+ return;
+ }
+ const message = messages[messages.length - 1];
+ renderMessage(message);
+}
+
+/** @param {number} user */
+function openUser(user) {
+ currentUser = user;
+
+ const messagesList = document.getElementById("friends-messages");
+ messagesList.innerHTML = "";
+
+ const messages = recievedMessages.get(user);
+ if (messages === undefined) {
+ return;
+ }
+ for (const message of messages) {
+ renderMessage(message);
+ }
+}
+
+function sendMessage() {
+ if (currentUser === 0) {
+ alert("Select a friend to message first");
+ return;
+ }
+
+ const textarea = document.getElementById("friends-textarea");
+ const content = textarea.value.trim();
+ if (content === "") {
+ return;
+ }
+
+ if (socket.readyState !== WebSocket.OPEN) {
+ alert("Not connected to the server. Refresh the page and try again.");
+ return;
+ }
+
+ socket.send(JSON.stringify({ type: 0, userId: currentUser, content: content }));
+
+ // optimistically show the message locally, since the server only
+ // relays messages between the two other parties, not back to the sender
+ if (recievedMessages.get(currentUser) === undefined) {
+ recievedMessages.set(currentUser, []);
+ }
+ const sentMessage = new Message(selfId, content);
+ recievedMessages.get(currentUser).push(sentMessage);
+ renderMessage(sentMessage);
+
+ textarea.value = "";
+}
\ No newline at end of file
diff --git a/client/settings.html b/client/settings.html
index 4ff3590..22dea6d 100644
--- a/client/settings.html
+++ b/client/settings.html
@@ -1,7 +1,7 @@
- posts
+ settings
@@ -14,6 +14,7 @@
diff --git a/client/style.css b/client/style.css
index 135658a..fdefda8 100644
--- a/client/style.css
+++ b/client/style.css
@@ -103,3 +103,32 @@ nav {
font-size: 16px;
}
}
+
+
+/* Friends-specific CSS */
+#friends {
+ display: flex;
+ padding-left: 16px;
+ flex-wrap: wrap;
+ gap: 1rem;
+ overflow: hidden;
+}
+
+#friends-list {
+ flex: 1;
+ min-width: 300px;
+ height: 100%;
+ overflow-y: auto;
+}
+
+#friends-messenger {
+ flex: 1;
+ min-width: 300px;
+}
+
+#friends-message-box {
+ position: fixed;
+ bottom: 0;
+ right: 0;
+ width: 50%;
+}
diff --git a/server/meson.build b/server/meson.build
index 2cc5bc1..fe3ccf5 100644
--- a/server/meson.build
+++ b/server/meson.build
@@ -10,8 +10,11 @@ sources = [
'src/generated/footer.cpp',
'src/generated/login.cpp',
'src/generated/settings.cpp',
+ 'src/generated/friends.cpp',
+ 'src/generated/script_friends.cpp',
'src/generated/style.cpp',
'src/generated/script.cpp',
+ 'src/generated/notify.cpp',
# bcrypt
'src/bcrypt/bcrypt.cpp',
diff --git a/server/src/json/json.hpp b/server/src/json/json.hpp
new file mode 100644
index 0000000..82d69f7
--- /dev/null
+++ b/server/src/json/json.hpp
@@ -0,0 +1,25526 @@
+// __ _____ _____ _____
+// __| | __| | | | JSON for Modern C++
+// | | |__ | | | | | | version 3.12.0
+// |_____|_____|_____|_|___| https://github.com/nlohmann/json
+//
+// SPDX-FileCopyrightText: 2013 - 2025 Niels Lohmann
+// SPDX-License-Identifier: MIT
+
+/****************************************************************************\
+ * Note on documentation: The source files contain links to the online *
+ * documentation of the public API at https://json.nlohmann.me. This URL *
+ * contains the most recent documentation and should also be applicable to *
+ * previous versions; documentation for deprecated functions is not *
+ * removed, but marked deprecated. See "Generate documentation" section in *
+ * file docs/README.md. *
+\****************************************************************************/
+
+#ifndef INCLUDE_NLOHMANN_JSON_HPP_
+#define INCLUDE_NLOHMANN_JSON_HPP_
+
+#include // all_of, find, for_each
+#include // nullptr_t, ptrdiff_t, size_t
+#include // hash, less
+#include // initializer_list
+#ifndef JSON_NO_IO
+ #include // istream, ostream
+#endif // JSON_NO_IO
+#include // random_access_iterator_tag
+#include // unique_ptr
+#include // string, stoi, to_string
+#include // declval, forward, move, pair, swap
+#include // vector
+
+// #include
+// __ _____ _____ _____
+// __| | __| | | | JSON for Modern C++
+// | | |__ | | | | | | version 3.12.0
+// |_____|_____|_____|_|___| https://github.com/nlohmann/json
+//
+// SPDX-FileCopyrightText: 2013 - 2025 Niels Lohmann
+// SPDX-License-Identifier: MIT
+
+
+
+#include
+
+// #include
+// __ _____ _____ _____
+// __| | __| | | | JSON for Modern C++
+// | | |__ | | | | | | version 3.12.0
+// |_____|_____|_____|_|___| https://github.com/nlohmann/json
+//
+// SPDX-FileCopyrightText: 2013 - 2025 Niels Lohmann
+// SPDX-License-Identifier: MIT
+
+
+
+// This file contains all macro definitions affecting or depending on the ABI
+
+#ifndef JSON_SKIP_LIBRARY_VERSION_CHECK
+ #if defined(NLOHMANN_JSON_VERSION_MAJOR) && defined(NLOHMANN_JSON_VERSION_MINOR) && defined(NLOHMANN_JSON_VERSION_PATCH)
+ #if NLOHMANN_JSON_VERSION_MAJOR != 3 || NLOHMANN_JSON_VERSION_MINOR != 12 || NLOHMANN_JSON_VERSION_PATCH != 0
+ #warning "Already included a different version of the library!"
+ #endif
+ #endif
+#endif
+
+#define NLOHMANN_JSON_VERSION_MAJOR 3 // NOLINT(modernize-macro-to-enum)
+#define NLOHMANN_JSON_VERSION_MINOR 12 // NOLINT(modernize-macro-to-enum)
+#define NLOHMANN_JSON_VERSION_PATCH 0 // NOLINT(modernize-macro-to-enum)
+
+#ifndef JSON_DIAGNOSTICS
+ #define JSON_DIAGNOSTICS 0
+#endif
+
+#ifndef JSON_DIAGNOSTIC_POSITIONS
+ #define JSON_DIAGNOSTIC_POSITIONS 0
+#endif
+
+#ifndef JSON_USE_LEGACY_DISCARDED_VALUE_COMPARISON
+ #define JSON_USE_LEGACY_DISCARDED_VALUE_COMPARISON 0
+#endif
+
+#if JSON_DIAGNOSTICS
+ #define NLOHMANN_JSON_ABI_TAG_DIAGNOSTICS _diag
+#else
+ #define NLOHMANN_JSON_ABI_TAG_DIAGNOSTICS
+#endif
+
+#if JSON_DIAGNOSTIC_POSITIONS
+ #define NLOHMANN_JSON_ABI_TAG_DIAGNOSTIC_POSITIONS _dp
+#else
+ #define NLOHMANN_JSON_ABI_TAG_DIAGNOSTIC_POSITIONS
+#endif
+
+#if JSON_USE_LEGACY_DISCARDED_VALUE_COMPARISON
+ #define NLOHMANN_JSON_ABI_TAG_LEGACY_DISCARDED_VALUE_COMPARISON _ldvcmp
+#else
+ #define NLOHMANN_JSON_ABI_TAG_LEGACY_DISCARDED_VALUE_COMPARISON
+#endif
+
+#ifndef NLOHMANN_JSON_NAMESPACE_NO_VERSION
+ #define NLOHMANN_JSON_NAMESPACE_NO_VERSION 0
+#endif
+
+// Construct the namespace ABI tags component
+#define NLOHMANN_JSON_ABI_TAGS_CONCAT_EX(a, b, c) json_abi ## a ## b ## c
+#define NLOHMANN_JSON_ABI_TAGS_CONCAT(a, b, c) \
+ NLOHMANN_JSON_ABI_TAGS_CONCAT_EX(a, b, c)
+
+#define NLOHMANN_JSON_ABI_TAGS \
+ NLOHMANN_JSON_ABI_TAGS_CONCAT( \
+ NLOHMANN_JSON_ABI_TAG_DIAGNOSTICS, \
+ NLOHMANN_JSON_ABI_TAG_LEGACY_DISCARDED_VALUE_COMPARISON, \
+ NLOHMANN_JSON_ABI_TAG_DIAGNOSTIC_POSITIONS)
+
+// Construct the namespace version component
+#define NLOHMANN_JSON_NAMESPACE_VERSION_CONCAT_EX(major, minor, patch) \
+ _v ## major ## _ ## minor ## _ ## patch
+#define NLOHMANN_JSON_NAMESPACE_VERSION_CONCAT(major, minor, patch) \
+ NLOHMANN_JSON_NAMESPACE_VERSION_CONCAT_EX(major, minor, patch)
+
+#if NLOHMANN_JSON_NAMESPACE_NO_VERSION
+#define NLOHMANN_JSON_NAMESPACE_VERSION
+#else
+#define NLOHMANN_JSON_NAMESPACE_VERSION \
+ NLOHMANN_JSON_NAMESPACE_VERSION_CONCAT(NLOHMANN_JSON_VERSION_MAJOR, \
+ NLOHMANN_JSON_VERSION_MINOR, \
+ NLOHMANN_JSON_VERSION_PATCH)
+#endif
+
+// Combine namespace components
+#define NLOHMANN_JSON_NAMESPACE_CONCAT_EX(a, b) a ## b
+#define NLOHMANN_JSON_NAMESPACE_CONCAT(a, b) \
+ NLOHMANN_JSON_NAMESPACE_CONCAT_EX(a, b)
+
+#ifndef NLOHMANN_JSON_NAMESPACE
+#define NLOHMANN_JSON_NAMESPACE \
+ nlohmann::NLOHMANN_JSON_NAMESPACE_CONCAT( \
+ NLOHMANN_JSON_ABI_TAGS, \
+ NLOHMANN_JSON_NAMESPACE_VERSION)
+#endif
+
+#ifndef NLOHMANN_JSON_NAMESPACE_BEGIN
+#define NLOHMANN_JSON_NAMESPACE_BEGIN \
+ namespace nlohmann \
+ { \
+ inline namespace NLOHMANN_JSON_NAMESPACE_CONCAT( \
+ NLOHMANN_JSON_ABI_TAGS, \
+ NLOHMANN_JSON_NAMESPACE_VERSION) \
+ {
+#endif
+
+#ifndef NLOHMANN_JSON_NAMESPACE_END
+#define NLOHMANN_JSON_NAMESPACE_END \
+ } /* namespace (inline namespace) NOLINT(readability/namespace) */ \
+ } // namespace nlohmann
+#endif
+
+// #include
+// __ _____ _____ _____
+// __| | __| | | | JSON for Modern C++
+// | | |__ | | | | | | version 3.12.0
+// |_____|_____|_____|_|___| https://github.com/nlohmann/json
+//
+// SPDX-FileCopyrightText: 2013 - 2025 Niels Lohmann
+// SPDX-License-Identifier: MIT
+
+
+
+#include // transform
+#include // array
+#include // forward_list
+#include // inserter, front_inserter, end
+#include