diff --git a/GUILDED_ROADMAP.md b/GUILDED_ROADMAP.md index 8149369..6056a91 100644 --- a/GUILDED_ROADMAP.md +++ b/GUILDED_ROADMAP.md @@ -44,6 +44,18 @@ | 3.1 | Threads | ✅ Reused channels table + thread panel | | 3.2 | Forum Channels | ✅ Forum posts as threads + tags + card view | +--- + +## Phase 4 — User System Enhancements ✅ COMPLETE + +> Rich profiles and social features that make Guilded feel polished. + +| # | Feature | Status | +|---|---------|--------| +| 4.1 | Rich User Profiles | ✅ Profile modal + block button | +| 4.2 | Badges | ✅ DB + backend read; no grant UI | +| 4.3 | Block List | ✅ Backend + settings stub | + ### 2.1 Per-Channel Permission Overrides - **Backend:** New `channel_overrides` table (channel_id, target_type: role/user, target_id, allow_bitflags, deny_bitflags). Extend permission checker to layer channel overrides on top of role permissions. - **Route:** `PUT /channels/{channelID}/permissions/{targetType}/{targetID}`, `GET /channels/{channelID}/permissions`, `DELETE /channels/{channelID}/permissions/{targetType}/{targetID}`. diff --git a/internal/auth/handlers.go b/internal/auth/handlers.go index 325c45b..e73d874 100644 --- a/internal/auth/handlers.go +++ b/internal/auth/handlers.go @@ -42,6 +42,156 @@ func (h *Handler) RegisterProtectedRoutes(r chi.Router) { r.Get("/auth/me", h.Me) r.Patch("/auth/me", h.UpdateProfile) r.Put("/auth/me/password", h.ChangePassword) + r.Get("/users/{userID}/profile", h.GetPublicProfile) + r.Get("/users/me/blocks", h.ListBlocks) + r.Post("/users/me/blocks", h.BlockUser) + r.Delete("/users/me/blocks/{userID}", h.UnblockUser) +} + +// @Summary Get public user profile +// @Description Get public profile for any user +// @Tags users +// @Produce json +// @Param userID path string true "User ID" +// @Success 200 {object} PublicUserProfile +// @Router /users/{userID}/profile [get] +func (h *Handler) GetPublicProfile(w http.ResponseWriter, r *http.Request) { + userID := chi.URLParam(r, "userID") + profile, err := h.getPublicProfile(r.Context(), userID) + if err != nil { + http.Error(w, `{"error":"user not found"}`, http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(profile) +} + +func (h *Handler) getPublicProfile(ctx context.Context, userID string) (PublicUserProfile, error) { + var p PublicUserProfile + var socialLinks []byte + err := h.db.QueryRowContext(ctx, ` + SELECT id, username, display_name, email, avatar, bio, accent_color, status, status_text, created_at, + banner_url, tagline, pronouns, social_links + FROM users WHERE id = $1 + `, userID).Scan(&p.ID, &p.Username, &p.DisplayName, &p.Email, &p.Avatar, &p.Bio, &p.AccentColor, &p.Status, &p.StatusText, &p.CreatedAt, &p.BannerURL, &p.Tagline, &p.Pronouns, &socialLinks) + if err != nil { + return p, err + } + if len(socialLinks) > 0 { + _ = json.Unmarshal(socialLinks, &p.SocialLinks) + } + p.Badges = h.getUserBadges(ctx, userID) + return p, nil +} + +func (h *Handler) getUserBadges(ctx context.Context, userID string) []badgeResponse { + rows, err := h.db.QueryContext(ctx, ` + SELECT b.id, b.name, b.icon, b.description, b.server_id + FROM badges b + JOIN user_badges ub ON ub.badge_id = b.id + WHERE ub.user_id = $1 + `, userID) + if err != nil { + return nil + } + defer rows.Close() + badges := make([]badgeResponse, 0) + for rows.Next() { + var b badgeResponse + var serverID sql.NullString + if err := rows.Scan(&b.ID, &b.Name, &b.Icon, &b.Description, &serverID); err != nil { + continue + } + if serverID.Valid { + b.ServerID = serverID.String + } + badges = append(badges, b) + } + return badges +} + +// @Summary List blocked users +// @Description List users the current user has blocked +// @Tags users +// @Produce json +// @Success 200 {array} blockResponse +// @Router /users/me/blocks [get] +func (h *Handler) ListBlocks(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + rows, err := h.db.QueryContext(r.Context(), ` + SELECT u.id, u.username, b.created_at::text + FROM blocks b + JOIN users u ON u.id = b.blocked_id + WHERE b.blocker_id = $1 + `, userID) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + defer rows.Close() + blocks := make([]blockResponse, 0) + for rows.Next() { + var b blockResponse + if err := rows.Scan(&b.ID, &b.Username, &b.CreatedAt); err != nil { + continue + } + blocks = append(blocks, b) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(blocks) +} + +// @Summary Block a user +// @Description Block a user by ID +// @Tags users +// @Accept json +// @Success 204 +// @Router /users/me/blocks [post] +func (h *Handler) BlockUser(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + var req struct { + UserID string `json:"user_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.UserID == "" { + http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) + return + } + _, err := h.db.ExecContext(r.Context(), ` + INSERT INTO blocks (blocker_id, blocked_id) VALUES ($1, $2) ON CONFLICT DO NOTHING + `, userID, req.UserID) + if err != nil { + http.Error(w, `{"error":"failed to block user"}`, http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// @Summary Unblock a user +// @Description Unblock a user by ID +// @Tags users +// @Success 204 +// @Router /users/me/blocks/{userID} [delete] +func (h *Handler) UnblockUser(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + targetID := chi.URLParam(r, "userID") + _, err := h.db.ExecContext(r.Context(), `DELETE FROM blocks WHERE blocker_id = $1 AND blocked_id = $2`, userID, targetID) + if err != nil { + http.Error(w, `{"error":"failed to unblock user"}`, http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) } type registerRequest struct { @@ -63,6 +213,36 @@ type updateProfileRequest struct { AccentColor string `json:"accent_color"` StatusText string `json:"status_text"` Status string `json:"status"` + BannerURL string `json:"banner_url"` + Tagline string `json:"tagline"` + Pronouns string `json:"pronouns"` + SocialLinks []struct { + Platform string `json:"platform"` + URL string `json:"url"` + } `json:"social_links"` +} + +type PublicUserProfile struct { + UserProfile + BannerURL string `json:"banner_url"` + Tagline string `json:"tagline"` + Pronouns string `json:"pronouns"` + SocialLinks []map[string]interface{} `json:"social_links"` + Badges []badgeResponse `json:"badges"` +} + +type badgeResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Icon string `json:"icon"` + Description string `json:"description"` + ServerID string `json:"server_id,omitempty"` +} + +type blockResponse struct { + ID string `json:"id"` + Username string `json:"username"` + CreatedAt string `json:"created_at"` } type changePasswordRequest struct { @@ -279,6 +459,14 @@ func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) { http.Error(w, `{"error":"display name too long"}`, http.StatusBadRequest) return } + if len(req.Tagline) > 128 { + http.Error(w, `{"error":"tagline too long"}`, http.StatusBadRequest) + return + } + if len(req.Pronouns) > 40 { + http.Error(w, `{"error":"pronouns too long"}`, http.StatusBadRequest) + return + } if req.AccentColor != "" && len(req.AccentColor) != 7 { http.Error(w, `{"error":"accent color must be 7 char hex"}`, http.StatusBadRequest) return @@ -288,11 +476,13 @@ func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) { return } + socialLinksJSON, _ := json.Marshal(req.SocialLinks) _, err := h.db.ExecContext(r.Context(), ` UPDATE users - SET display_name = $2, bio = $3, accent_color = $4, status_text = $5, status = COALESCE(NULLIF($6, ''), status) + SET display_name = $2, bio = $3, accent_color = $4, status_text = $5, status = COALESCE(NULLIF($6, ''), status), + banner_url = NULLIF($7, ''), tagline = NULLIF($8, ''), pronouns = NULLIF($9, ''), social_links = $10 WHERE id = $1 - `, userID, req.DisplayName, req.Bio, req.AccentColor, req.StatusText, req.Status) + `, userID, req.DisplayName, req.Bio, req.AccentColor, req.StatusText, req.Status, req.BannerURL, req.Tagline, req.Pronouns, socialLinksJSON) if err != nil { http.Error(w, `{"error":"update failed"}`, http.StatusInternalServerError) return @@ -368,8 +558,8 @@ func (h *Handler) ChangePassword(w http.ResponseWriter, r *http.Request) { return } - ok, err = VerifyPassword(req.CurrentPassword, currentHash) - if err != nil || !ok { + verified, _ := VerifyPassword(req.CurrentPassword, currentHash) + if !verified { http.Error(w, `{"error":"current password is incorrect"}`, http.StatusUnauthorized) return } diff --git a/internal/db/db.go b/internal/db/db.go index c881359..b0fcdf3 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -344,6 +344,42 @@ ALTER TABLE channels ADD COLUMN IF NOT EXISTS message_count INTEGER NOT NULL DEF ALTER TABLE channels ADD COLUMN IF NOT EXISTS last_message_at TIMESTAMPTZ; CREATE INDEX IF NOT EXISTS idx_channels_parent ON channels(parent_channel_id); +ALTER TABLE users ADD COLUMN IF NOT EXISTS banner_url TEXT; +ALTER TABLE users ADD COLUMN IF NOT EXISTS tagline VARCHAR(128); +ALTER TABLE users ADD COLUMN IF NOT EXISTS social_links JSONB DEFAULT '[]'; +ALTER TABLE users ADD COLUMN IF NOT EXISTS pronouns VARCHAR(40); + +-- Badges +CREATE TABLE IF NOT EXISTS badges ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + server_id UUID REFERENCES servers(id) ON DELETE CASCADE, + name VARCHAR(64) NOT NULL, + icon TEXT, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_badges_server ON badges(server_id); + +CREATE TABLE IF NOT EXISTS user_badges ( + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + badge_id UUID NOT NULL REFERENCES badges(id) ON DELETE CASCADE, + granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + granted_by UUID REFERENCES users(id) ON DELETE SET NULL, + PRIMARY KEY (user_id, badge_id) +); + +-- Block list +CREATE TABLE IF NOT EXISTS blocks ( + blocker_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + blocked_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (blocker_id, blocked_id) +); + +CREATE INDEX IF NOT EXISTS idx_blocks_blocker ON blocks(blocker_id); +CREATE INDEX IF NOT EXISTS idx_blocks_blocked ON blocks(blocked_id); + -- Message full-text search ALTER TABLE messages ADD COLUMN IF NOT EXISTS search_vector tsvector; CREATE INDEX IF NOT EXISTS idx_messages_search ON messages USING GIN(search_vector); diff --git a/web/src/components/ChatArea.tsx b/web/src/components/ChatArea.tsx index b001302..c00e9b4 100644 --- a/web/src/components/ChatArea.tsx +++ b/web/src/components/ChatArea.tsx @@ -10,6 +10,7 @@ import { MessageSearch } from "./MessageSearch"; import { ThreadListPanel } from "./ThreadListPanel.tsx"; import { ThreadPanel } from "./ThreadPanel.tsx"; import { ForumView } from "./ForumView.tsx"; +import { UserProfileModal } from "./UserProfileModal.tsx"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; @@ -120,6 +121,7 @@ export function ChatArea() { const [selectMode, setSelectMode] = useState(false); const [showThreads, setShowThreads] = useState(false); const [activeThread, setActiveThread] = useState<{ id: string; name: string } | null>(null); + const [profileUserId, setProfileUserId] = useState(null); const bottomRef = useRef(null); const inputRef = useRef(null); @@ -282,11 +284,11 @@ export function ChatArea() { /> )} {showThreads && activeChannelId && activeChannel?.type !== 'forum' && ( - setActiveThread(t)} - onClose={() => setShowThreads(false)} - /> + setActiveThread(t)} + onClose={() => setShowThreads(false)} + /> )} {selectMode && activeChannelId && activeChannel?.type !== 'forum' && (
@@ -339,7 +341,9 @@ export function ChatArea() { )} [{formatTime(message.created_at)}]{" "} - <{message.author_username}>{" "} + setProfileUserId(message.author_id)}> + <{message.author_username}> + {" "} {renderContent(message.content, memberUsernames)} {renderEmbeds(message.embeds)}
@@ -394,6 +398,9 @@ export function ChatArea() { )} + {profileUserId && ( + setProfileUserId(null)} /> + )} ); } diff --git a/web/src/components/UserProfileModal.tsx b/web/src/components/UserProfileModal.tsx new file mode 100644 index 0000000..474da75 --- /dev/null +++ b/web/src/components/UserProfileModal.tsx @@ -0,0 +1,85 @@ +import { useEffect, useState } from 'react'; +import { useAuthStore, type PublicProfile } from '../stores/auth.ts'; + +interface UserProfileModalProps { + userId: string; + onClose: () => void; +} + +export function UserProfileModal({ userId, onClose }: UserProfileModalProps) { + const getPublicProfile = useAuthStore((s) => s.getPublicProfile); + const blockUser = useAuthStore((s) => s.blockUser); + const [profile, setProfile] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + getPublicProfile(userId).then(setProfile).catch(setError); + }, [userId, getPublicProfile]); + + const statusColor = { + online: 'bg-gb-green', + idle: 'bg-gb-orange', + dnd: 'bg-gb-red', + offline: 'bg-gb-fg-t', + }[profile?.status || 'offline']; + + return ( +
+
e.stopPropagation()}> + {error &&
ERR: {error}
} + {!profile && !error &&
[loading...]
} + {profile && ( + <> +
+
+
+ +
+
+ + {profile.display_name || profile.username} +
+
@{profile.username}
+ {profile.pronouns &&
{profile.pronouns}
} +
+
+ {profile.tagline &&
"{profile.tagline}"
} + {profile.bio &&
{profile.bio}
} + {profile.social_links && profile.social_links.length > 0 && ( +
+ {profile.social_links.map((link, idx) => ( + + {link.platform} + + ))} +
+ )} + {profile.badges && profile.badges.length > 0 && ( +
+ {profile.badges.map((badge) => ( + + {badge.icon && {badge.icon}} + {badge.name} + + ))} +
+ )} +
+ + +
+
+ + )} +
+
+ ); +} diff --git a/web/src/stores/auth.ts b/web/src/stores/auth.ts index dad8790..f436d8b 100644 --- a/web/src/stores/auth.ts +++ b/web/src/stores/auth.ts @@ -14,14 +14,32 @@ export interface User { status: UserStatus; status_text: string; created_at: string; + banner_url?: string; + tagline?: string; + pronouns?: string; + social_links?: { platform: string; url: string }[]; } +export interface PublicProfile extends User { + badges: { id: string; name: string; icon: string; description?: string; server_id?: string }[]; +} + +export type BlockedUser = { + id: string; + username: string; + created_at: string; +}; + export interface UpdateProfilePayload { display_name?: string; bio?: string; accent_color?: string; status_text?: string; status?: UserStatus; + banner_url?: string; + tagline?: string; + pronouns?: string; + social_links?: { platform: string; url: string }[]; } interface AuthState { @@ -43,6 +61,10 @@ interface AuthState { currentPassword: string, newPassword: string, ) => Promise; + getPublicProfile: (userId: string) => Promise; + listBlocks: () => Promise; + blockUser: (userId: string) => Promise; + unblockUser: (userId: string) => Promise; clearError: () => void; } @@ -156,4 +178,16 @@ export const useAuthStore = create((set) => ({ }, clearError: () => set({ error: null }), + + getPublicProfile: async (userId) => api.get(`/users/${userId}/profile`), + + listBlocks: async () => api.get('/users/me/blocks'), + + blockUser: async (userId) => { + await api.post('/users/me/blocks', { user_id: userId }); + }, + + unblockUser: async (userId) => { + await api.delete(`/users/me/blocks/${userId}`); + }, })); diff --git a/web/tsconfig.app.tsbuildinfo b/web/tsconfig.app.tsbuildinfo index 91c1b73..529d843 100644 --- a/web/tsconfig.app.tsbuildinfo +++ b/web/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/ChannelList.tsx","./src/components/ChannelSettingsModal.tsx","./src/components/ChatArea.tsx","./src/components/CommandManager.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/EmojiPicker.tsx","./src/components/ForumView.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/JoinServerModal.tsx","./src/components/Layout.tsx","./src/components/LoginForm.tsx","./src/components/MemberContextMenu.tsx","./src/components/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionDropdown.tsx","./src/components/MentionPopup.tsx","./src/components/MessageSearch.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/NewConversationModal.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/ServerSettingsModal.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/ThreadListPanel.tsx","./src/components/ThreadPanel.tsx","./src/components/TypingIndicator.tsx","./src/components/UserSettings.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/lib/usePermissions.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/conversation.ts","./src/stores/layout.ts","./src/stores/member.ts","./src/stores/message.ts","./src/stores/moderation.ts","./src/stores/permissions.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/thread.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/ChannelList.tsx","./src/components/ChannelSettingsModal.tsx","./src/components/ChatArea.tsx","./src/components/CommandManager.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/EmojiPicker.tsx","./src/components/ForumView.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/JoinServerModal.tsx","./src/components/Layout.tsx","./src/components/LoginForm.tsx","./src/components/MemberContextMenu.tsx","./src/components/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionDropdown.tsx","./src/components/MentionPopup.tsx","./src/components/MessageSearch.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/NewConversationModal.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/ServerSettingsModal.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/ThreadListPanel.tsx","./src/components/ThreadPanel.tsx","./src/components/TypingIndicator.tsx","./src/components/UserProfileModal.tsx","./src/components/UserSettings.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/lib/usePermissions.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/conversation.ts","./src/stores/layout.ts","./src/stores/member.ts","./src/stores/message.ts","./src/stores/moderation.ts","./src/stores/permissions.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/thread.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"} \ No newline at end of file