Skip to content

Commit f3b74f9

Browse files
committed
feat: private messages, trending topics, profile, settings, dark mode
- Backend: POST /api/messages, GET /api/messages/conversations, GET /api/messages/conversation, GET /api/trending (hashtag extraction) - Frontend: Messages page (conversation list + thread + new message modal) - Frontend: Explore page enhanced with live trending hashtags from API - Frontend: Profile page wired to real user data - Frontend: Settings page with functional dark mode toggle and sign-out - Frontend: Dark mode throughout (Tailwind class strategy via ThemeContext) - Frontend: Sidebar (desktop) + BottomNav (mobile) navigation in Nav.tsx - Add .gitignore (excludes dist, node_modules, .env, Go binaries, Terraform state)
1 parent e753656 commit f3b74f9

20 files changed

Lines changed: 1160 additions & 276 deletions

File tree

.gitignore

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# ── Node / Frontend ──────────────────────────────────────────
2+
node_modules/
3+
frontend/dist/
4+
frontend/.env
5+
frontend/.env.local
6+
frontend/.env.*.local
7+
*.tsbuildinfo
8+
9+
# ── Go / Backend ─────────────────────────────────────────────
10+
backend/bin/
11+
backend/*.exe
12+
backend/*.test
13+
*.out
14+
15+
# ── Environment & Secrets ────────────────────────────────────
16+
.env
17+
.env.*
18+
!.env.example
19+
infrastructure/.env
20+
*.pem
21+
*.key
22+
23+
# ── Terraform ────────────────────────────────────────────────
24+
infrastructure/terraform/.terraform/
25+
infrastructure/terraform/.terraform.lock.hcl
26+
infrastructure/terraform/terraform.tfstate
27+
infrastructure/terraform/terraform.tfstate.backup
28+
infrastructure/terraform/*.tfvars
29+
infrastructure/terraform/override.tf
30+
infrastructure/terraform/override.tf.json
31+
32+
# ── Docker ───────────────────────────────────────────────────
33+
.docker/
34+
35+
# ── OS & Editor ──────────────────────────────────────────────
36+
.DS_Store
37+
Thumbs.db
38+
desktop.ini
39+
.vscode/
40+
.idea/
41+
*.swp
42+
*.swo
43+
*~
44+
45+
# ── Logs & Temp ──────────────────────────────────────────────
46+
*.log
47+
logs/
48+
tmp/
49+
temp/

backend/cmd/api/main.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ func main() {
2525
UserPasswords: make(map[string]string),
2626
FriendRequests: make(map[string]*models.FriendRequest),
2727
Locations: make(map[string]*models.Location),
28+
Messages: make(map[string]*models.Message),
2829
}
2930

3031
// Define our routes (Requires Go 1.22+ for method-based routing)
@@ -49,6 +50,14 @@ func main() {
4950
mux.HandleFunc("POST /api/locations", api.EnableCORS(srv.HandleShareLocation))
5051
mux.HandleFunc("GET /api/locations/nearby", api.EnableCORS(srv.HandleGetNearbyUsers))
5152

53+
// Message routes
54+
mux.HandleFunc("POST /api/messages", api.EnableCORS(srv.HandleSendMessage))
55+
mux.HandleFunc("GET /api/messages/conversations", api.EnableCORS(srv.HandleGetConversations))
56+
mux.HandleFunc("GET /api/messages/conversation", api.EnableCORS(srv.HandleGetConversation))
57+
58+
// Trending route
59+
mux.HandleFunc("GET /api/trending", api.EnableCORS(srv.HandleGetTrending))
60+
5261
// Start the server
5362
port := ":8080"
5463
log.Printf("Backend server starting on port %s...\n", port)

backend/internal/api/handlers.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ type Server struct {
1818
UserPasswords map[string]string // In-memory password storage (demo only)
1919
FriendRequests map[string]*models.FriendRequest
2020
Locations map[string]*models.Location
21+
Messages map[string]*models.Message
2122
}
2223

2324
// HandleLogin authenticates a user

backend/internal/api/messages.go

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
package api
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"sort"
7+
"strings"
8+
"time"
9+
10+
"github.com/mtepenner/brevity-sharing/internal/models"
11+
)
12+
13+
// userByID looks up a user by their ID across the username-keyed Users map.
14+
func (s *Server) userByID(id string) *models.User {
15+
for _, u := range s.Users {
16+
if u.ID == id {
17+
return u
18+
}
19+
}
20+
return nil
21+
}
22+
23+
// HandleSendMessage sends a private message from one user to another.
24+
func (s *Server) HandleSendMessage(w http.ResponseWriter, r *http.Request) {
25+
var req struct {
26+
SenderID string `json:"sender_id"`
27+
RecipientID string `json:"recipient_id"`
28+
Content string `json:"content"`
29+
}
30+
31+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
32+
http.Error(w, "Invalid request payload", http.StatusBadRequest)
33+
return
34+
}
35+
36+
if req.SenderID == "" || req.RecipientID == "" || req.Content == "" {
37+
http.Error(w, "sender_id, recipient_id, and content are required", http.StatusBadRequest)
38+
return
39+
}
40+
41+
if len(req.Content) > 1000 {
42+
http.Error(w, "Message too long (max 1000 characters)", http.StatusBadRequest)
43+
return
44+
}
45+
46+
if req.SenderID == req.RecipientID {
47+
http.Error(w, "Cannot send a message to yourself", http.StatusBadRequest)
48+
return
49+
}
50+
51+
msg := &models.Message{
52+
ID: generateID(),
53+
SenderID: req.SenderID,
54+
RecipientID: req.RecipientID,
55+
Content: req.Content,
56+
CreatedAt: time.Now(),
57+
}
58+
59+
s.Messages[msg.ID] = msg
60+
61+
w.Header().Set("Content-Type", "application/json")
62+
w.WriteHeader(http.StatusCreated)
63+
json.NewEncoder(w).Encode(msg)
64+
}
65+
66+
// Conversation summarises the last message and unread count for a conversation partner.
67+
type Conversation struct {
68+
PartnerID string `json:"partner_id"`
69+
PartnerUsername string `json:"partner_username"`
70+
LastMessage *models.Message `json:"last_message"`
71+
UnreadCount int `json:"unread_count"`
72+
}
73+
74+
// HandleGetConversations returns a list of conversations for a given user.
75+
func (s *Server) HandleGetConversations(w http.ResponseWriter, r *http.Request) {
76+
userID := r.URL.Query().Get("user_id")
77+
if userID == "" {
78+
http.Error(w, "user_id required", http.StatusBadRequest)
79+
return
80+
}
81+
82+
convMap := make(map[string]*Conversation)
83+
84+
for _, msg := range s.Messages {
85+
if msg.SenderID != userID && msg.RecipientID != userID {
86+
continue
87+
}
88+
89+
partnerID := msg.RecipientID
90+
if msg.RecipientID == userID {
91+
partnerID = msg.SenderID
92+
}
93+
94+
conv, exists := convMap[partnerID]
95+
if !exists {
96+
partnerUsername := partnerID
97+
if partner := s.userByID(partnerID); partner != nil {
98+
partnerUsername = partner.Username
99+
}
100+
conv = &Conversation{
101+
PartnerID: partnerID,
102+
PartnerUsername: partnerUsername,
103+
}
104+
convMap[partnerID] = conv
105+
}
106+
107+
if conv.LastMessage == nil || msg.CreatedAt.After(conv.LastMessage.CreatedAt) {
108+
msgCopy := *msg
109+
conv.LastMessage = &msgCopy
110+
}
111+
112+
if msg.RecipientID == userID && msg.ReadAt == nil {
113+
conv.UnreadCount++
114+
}
115+
}
116+
117+
conversations := make([]*Conversation, 0, len(convMap))
118+
for _, conv := range convMap {
119+
conversations = append(conversations, conv)
120+
}
121+
122+
sort.Slice(conversations, func(i, j int) bool {
123+
if conversations[i].LastMessage == nil {
124+
return false
125+
}
126+
if conversations[j].LastMessage == nil {
127+
return true
128+
}
129+
return conversations[i].LastMessage.CreatedAt.After(conversations[j].LastMessage.CreatedAt)
130+
})
131+
132+
w.Header().Set("Content-Type", "application/json")
133+
json.NewEncoder(w).Encode(conversations)
134+
}
135+
136+
// HandleGetConversation returns all messages exchanged between two users.
137+
func (s *Server) HandleGetConversation(w http.ResponseWriter, r *http.Request) {
138+
user1 := r.URL.Query().Get("user1")
139+
user2 := r.URL.Query().Get("user2")
140+
141+
if user1 == "" || user2 == "" {
142+
http.Error(w, "user1 and user2 required", http.StatusBadRequest)
143+
return
144+
}
145+
146+
messages := make([]models.Message, 0)
147+
for _, msg := range s.Messages {
148+
if (msg.SenderID == user1 && msg.RecipientID == user2) ||
149+
(msg.SenderID == user2 && msg.RecipientID == user1) {
150+
messages = append(messages, *msg)
151+
}
152+
}
153+
154+
sort.Slice(messages, func(i, j int) bool {
155+
return messages[i].CreatedAt.Before(messages[j].CreatedAt)
156+
})
157+
158+
w.Header().Set("Content-Type", "application/json")
159+
json.NewEncoder(w).Encode(messages)
160+
}
161+
162+
// HandleGetTrending returns the top trending hashtags extracted from stored tweets.
163+
func (s *Server) HandleGetTrending(w http.ResponseWriter, r *http.Request) {
164+
tweets := s.DB.GetTimeline()
165+
166+
topicCounts := make(map[string]int)
167+
for _, tweet := range tweets {
168+
for _, word := range strings.Fields(tweet.Content) {
169+
word = strings.Trim(word, ".,!?\"';:()")
170+
if strings.HasPrefix(word, "#") && len(word) > 1 {
171+
topicCounts[strings.ToLower(word)]++
172+
}
173+
}
174+
}
175+
176+
type TrendingTopic struct {
177+
Topic string `json:"topic"`
178+
Count int `json:"count"`
179+
}
180+
181+
topics := make([]TrendingTopic, 0, len(topicCounts))
182+
for topic, count := range topicCounts {
183+
topics = append(topics, TrendingTopic{Topic: topic, Count: count})
184+
}
185+
186+
sort.Slice(topics, func(i, j int) bool {
187+
return topics[i].Count > topics[j].Count
188+
})
189+
190+
if len(topics) > 10 {
191+
topics = topics[:10]
192+
}
193+
194+
w.Header().Set("Content-Type", "application/json")
195+
json.NewEncoder(w).Encode(topics)
196+
}

backend/internal/models/message.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package models
2+
3+
import "time"
4+
5+
// Message represents a private message between two users
6+
type Message struct {
7+
ID string `json:"id"`
8+
SenderID string `json:"sender_id"`
9+
RecipientID string `json:"recipient_id"`
10+
Content string `json:"content"`
11+
CreatedAt time.Time `json:"created_at"`
12+
ReadAt *time.Time `json:"read_at,omitempty"`
13+
}

0 commit comments

Comments
 (0)