diff --git a/go-backend/cmd/api/main.go b/go-backend/cmd/api/main.go index 60ede5a..a3c63ed 100644 --- a/go-backend/cmd/api/main.go +++ b/go-backend/cmd/api/main.go @@ -99,6 +99,7 @@ func main() { chatRepo := repository.NewChatRepository(dbPool) categoryRepo := repository.NewCategoryRepository(dbPool) notifRepo := repository.NewNotificationRepository(dbPool) + tagRepo := repository.NewTagRepository(dbPool) assetService := services.NewAssetService(cfg.R2SigningSecret, cfg.R2PublicBaseURL, cfg.R2ImgDomain, cfg.R2VidDomain) feedService := services.NewFeedService(postRepo, assetService) @@ -108,15 +109,16 @@ func main() { log.Warn().Err(err).Msg("Failed to initialize PushService") } - notificationService := services.NewNotificationService(notifRepo, pushService) + notificationService := services.NewNotificationService(notifRepo, pushService, userRepo) emailService := services.NewEmailService(cfg) + moderationService := services.NewModerationService(dbPool) hub := realtime.NewHub() wsHandler := handlers.NewWSHandler(hub, cfg.JWTSecret) userHandler := handlers.NewUserHandler(userRepo, postRepo, pushService, assetService) - postHandler := handlers.NewPostHandler(postRepo, userRepo, feedService, assetService, notificationService) + postHandler := handlers.NewPostHandler(postRepo, userRepo, feedService, assetService, notificationService, moderationService) chatHandler := handlers.NewChatHandler(chatRepo, pushService, hub) authHandler := handlers.NewAuthHandler(userRepo, cfg, emailService) categoryHandler := handlers.NewCategoryHandler(categoryRepo) @@ -203,6 +205,13 @@ func main() { users.GET("/:id/posts", postHandler.GetProfilePosts) users.GET("/:id/saved", userHandler.GetSavedPosts) users.GET("/me/liked", userHandler.GetLikedPosts) + users.POST("/:id/block", userHandler.BlockUser) + users.DELETE("/:id/block", userHandler.UnblockUser) + users.GET("/blocked", userHandler.GetBlockedUsers) + users.POST("/report", userHandler.ReportUser) + users.POST("/block_by_handle", userHandler.BlockUserByHandle) + users.DELETE("/notifications/device", userHandler.RemoveFCMToken) + users.POST("/notifications/device", userHandler.RegisterFCMToken) } authorized.POST("/posts", postHandler.CreatePost) @@ -271,15 +280,29 @@ func main() { // Media routes authorized.POST("/upload", mediaHandler.Upload) - // Search route - searchHandler := handlers.NewSearchHandler(userRepo, postRepo, assetService) - authorized.GET("/search", searchHandler.Search) + // Search & Discover routes + discoverHandler := handlers.NewDiscoverHandler(userRepo, postRepo, tagRepo, categoryRepo, assetService) + authorized.GET("/search", discoverHandler.Search) + authorized.GET("/discover", discoverHandler.GetDiscover) + authorized.GET("/hashtags/trending", discoverHandler.GetTrendingHashtags) + authorized.GET("/hashtags/following", discoverHandler.GetFollowedHashtags) + authorized.GET("/hashtags/:name", discoverHandler.GetHashtagPage) + authorized.POST("/hashtags/:name/follow", discoverHandler.FollowHashtag) + authorized.DELETE("/hashtags/:name/follow", discoverHandler.UnfollowHashtag) // Notifications - notificationHandler := handlers.NewNotificationHandler(notifRepo) + notificationHandler := handlers.NewNotificationHandler(notifRepo, notificationService) authorized.GET("/notifications", notificationHandler.GetNotifications) - authorized.POST("/notifications/device", settingsHandler.RegisterDevice) - authorized.DELETE("/notifications/device", settingsHandler.UnregisterDevice) + authorized.GET("/notifications/unread", notificationHandler.GetUnreadCount) + authorized.GET("/notifications/badge", notificationHandler.GetBadgeCount) + authorized.PUT("/notifications/:id/read", notificationHandler.MarkAsRead) + authorized.PUT("/notifications/read-all", notificationHandler.MarkAllAsRead) + authorized.DELETE("/notifications/:id", notificationHandler.DeleteNotification) + authorized.GET("/notifications/preferences", notificationHandler.GetNotificationPreferences) + authorized.PUT("/notifications/preferences", notificationHandler.UpdateNotificationPreferences) + authorized.POST("/notifications/device", notificationHandler.RegisterDevice) + authorized.DELETE("/notifications/device", notificationHandler.UnregisterDevice) + authorized.DELETE("/notifications/devices", notificationHandler.UnregisterAllDevices) } } diff --git a/go-backend/cmd/migrate_new/main.go b/go-backend/cmd/migrate_new/main.go new file mode 100644 index 0000000..8e10b65 --- /dev/null +++ b/go-backend/cmd/migrate_new/main.go @@ -0,0 +1,85 @@ +package main + +import ( + "context" + "io/ioutil" + "os" + "path/filepath" + "time" + + "github.com/patbritton/sojorn-backend/internal/config" + "github.com/patbritton/sojorn-backend/internal/database" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +func main() { + // Setup logging + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) + + // Load configuration + cfg := config.LoadConfig() + if cfg.DatabaseURL == "" { + log.Fatal().Msg("DATABASE_URL environment variable is not set") + } + + // Connect to database + log.Info().Msg("Connecting to database...") + pool, err := database.Connect(cfg.DatabaseURL) + if err != nil { + log.Fatal().Err(err).Msg("Failed to connect to database") + } + defer pool.Close() + + migrationsDir := "internal/database/migrations" + + // New migrations to apply + migrations := []string{ + "000010_notification_preferences.up.sql", + "000011_tagging_system.up.sql", + } + + ctx := context.Background() + + for _, filename := range migrations { + log.Info().Msgf("Applying migration: %s", filename) + + content, err := ioutil.ReadFile(filepath.Join(migrationsDir, filename)) + if err != nil { + log.Error().Err(err).Msgf("Failed to read file: %s", filename) + continue + } + + ctx, cancel := context.WithTimeout(ctx, 60*time.Second) + _, err = pool.Exec(ctx, string(content)) + cancel() + + if err != nil { + // Check if the error is just "already exists" + errStr := err.Error() + if contains(errStr, "already exists") || contains(errStr, "duplicate key") { + log.Warn().Msgf("Migration %s may have already been applied (partial): %s", filename, errStr) + continue + } + log.Error().Err(err).Msgf("Failed to execute migration: %s", filename) + os.Exit(1) + } + + log.Info().Msgf("Successfully applied: %s", filename) + } + + log.Info().Msg("All new migrations applied successfully.") +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsRune(s, substr)) +} + +func containsRune(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/go-backend/internal/database/migrations/000010_notification_preferences.down.sql b/go-backend/internal/database/migrations/000010_notification_preferences.down.sql new file mode 100644 index 0000000..30938ce --- /dev/null +++ b/go-backend/internal/database/migrations/000010_notification_preferences.down.sql @@ -0,0 +1,10 @@ +-- 000010_notification_preferences.down.sql + +DROP TRIGGER IF EXISTS notification_count_trigger ON notifications; +DROP FUNCTION IF EXISTS update_unread_notification_count(); + +ALTER TABLE notifications DROP COLUMN IF EXISTS group_key; +ALTER TABLE notifications DROP COLUMN IF EXISTS priority; +ALTER TABLE profiles DROP COLUMN IF EXISTS unread_notification_count; + +DROP TABLE IF EXISTS notification_preferences; diff --git a/go-backend/internal/database/migrations/000010_notification_preferences.up.sql b/go-backend/internal/database/migrations/000010_notification_preferences.up.sql new file mode 100644 index 0000000..6b8eb34 --- /dev/null +++ b/go-backend/internal/database/migrations/000010_notification_preferences.up.sql @@ -0,0 +1,72 @@ +-- 000010_notification_preferences.up.sql +-- User notification preferences for granular control + +CREATE TABLE IF NOT EXISTS notification_preferences ( + user_id UUID PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE, + + -- Push notification toggles + push_enabled BOOLEAN NOT NULL DEFAULT TRUE, + push_likes BOOLEAN NOT NULL DEFAULT TRUE, + push_comments BOOLEAN NOT NULL DEFAULT TRUE, + push_replies BOOLEAN NOT NULL DEFAULT TRUE, + push_mentions BOOLEAN NOT NULL DEFAULT TRUE, + push_follows BOOLEAN NOT NULL DEFAULT TRUE, + push_follow_requests BOOLEAN NOT NULL DEFAULT TRUE, + push_messages BOOLEAN NOT NULL DEFAULT TRUE, + push_saves BOOLEAN NOT NULL DEFAULT TRUE, + push_beacons BOOLEAN NOT NULL DEFAULT TRUE, + + -- Email notification toggles (for future use) + email_enabled BOOLEAN NOT NULL DEFAULT FALSE, + email_digest_frequency TEXT NOT NULL DEFAULT 'never', -- 'never', 'daily', 'weekly' + + -- Quiet hours (UTC) + quiet_hours_enabled BOOLEAN NOT NULL DEFAULT FALSE, + quiet_hours_start TIME, -- e.g., '22:00:00' + quiet_hours_end TIME, -- e.g., '08:00:00' + + -- Badge settings + show_badge_count BOOLEAN NOT NULL DEFAULT TRUE, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Add unread count cache to profiles for badge display +ALTER TABLE profiles ADD COLUMN IF NOT EXISTS unread_notification_count INTEGER NOT NULL DEFAULT 0; + +-- Add group_key for notification batching/grouping +ALTER TABLE notifications ADD COLUMN IF NOT EXISTS group_key TEXT; +ALTER TABLE notifications ADD COLUMN IF NOT EXISTS priority TEXT NOT NULL DEFAULT 'normal'; -- 'low', 'normal', 'high', 'urgent' + +-- Create indexes for efficient querying +CREATE INDEX IF NOT EXISTS idx_notifications_user_unread ON notifications(user_id, is_read) WHERE is_read = FALSE; +CREATE INDEX IF NOT EXISTS idx_notifications_group_key ON notifications(group_key) WHERE group_key IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_fcm_tokens_user ON user_fcm_tokens(user_id); + +-- Function to update unread count on notification insert +CREATE OR REPLACE FUNCTION update_unread_notification_count() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + UPDATE profiles SET unread_notification_count = unread_notification_count + 1 WHERE id = NEW.user_id; + ELSIF TG_OP = 'UPDATE' AND OLD.is_read = FALSE AND NEW.is_read = TRUE THEN + UPDATE profiles SET unread_notification_count = GREATEST(0, unread_notification_count - 1) WHERE id = NEW.user_id; + ELSIF TG_OP = 'DELETE' AND OLD.is_read = FALSE THEN + UPDATE profiles SET unread_notification_count = GREATEST(0, unread_notification_count - 1) WHERE id = OLD.user_id; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Create trigger for automatic badge count updates +DROP TRIGGER IF EXISTS notification_count_trigger ON notifications; +CREATE TRIGGER notification_count_trigger + AFTER INSERT OR UPDATE OR DELETE ON notifications + FOR EACH ROW + EXECUTE FUNCTION update_unread_notification_count(); + +-- Initialize notification preferences for existing users +INSERT INTO notification_preferences (user_id) +SELECT id FROM profiles +ON CONFLICT (user_id) DO NOTHING; diff --git a/go-backend/internal/database/migrations/000011_tagging_system.down.sql b/go-backend/internal/database/migrations/000011_tagging_system.down.sql new file mode 100644 index 0000000..ec6fa0f --- /dev/null +++ b/go-backend/internal/database/migrations/000011_tagging_system.down.sql @@ -0,0 +1,12 @@ +-- 000011_tagging_system.down.sql + +DROP TRIGGER IF EXISTS hashtag_count_trigger ON post_hashtags; +DROP FUNCTION IF EXISTS update_hashtag_count(); +DROP FUNCTION IF EXISTS calculate_trending_scores(); + +DROP TABLE IF EXISTS suggested_users; +DROP TABLE IF EXISTS trending_hashtags; +DROP TABLE IF EXISTS post_mentions; +DROP TABLE IF EXISTS hashtag_follows; +DROP TABLE IF EXISTS post_hashtags; +DROP TABLE IF EXISTS hashtags; diff --git a/go-backend/internal/database/migrations/000011_tagging_system.up.sql b/go-backend/internal/database/migrations/000011_tagging_system.up.sql new file mode 100644 index 0000000..23b13ed --- /dev/null +++ b/go-backend/internal/database/migrations/000011_tagging_system.up.sql @@ -0,0 +1,133 @@ +-- 000011_tagging_system.up.sql +-- Complete tagging system for hashtags and mentions + +-- Hashtags master table +CREATE TABLE IF NOT EXISTS hashtags ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name TEXT NOT NULL, -- lowercase, without # + display_name TEXT NOT NULL, -- original casing + use_count INTEGER NOT NULL DEFAULT 0, + trending_score FLOAT NOT NULL DEFAULT 0, + is_trending BOOLEAN NOT NULL DEFAULT FALSE, + is_featured BOOLEAN NOT NULL DEFAULT FALSE, + category TEXT, -- optional category grouping + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(name) +); + +-- Post-to-hashtag junction table +CREATE TABLE IF NOT EXISTS post_hashtags ( + post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE, + hashtag_id UUID NOT NULL REFERENCES hashtags(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (post_id, hashtag_id) +); + +-- User-to-hashtag follows (users can follow hashtags) +CREATE TABLE IF NOT EXISTS hashtag_follows ( + user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + hashtag_id UUID NOT NULL REFERENCES hashtags(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, hashtag_id) +); + +-- Post mentions (tracks @mentions in posts) +CREATE TABLE IF NOT EXISTS post_mentions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE, + mentioned_user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(post_id, mentioned_user_id) +); + +-- Trending hashtags snapshot (for discover page) +CREATE TABLE IF NOT EXISTS trending_hashtags ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + hashtag_id UUID NOT NULL REFERENCES hashtags(id) ON DELETE CASCADE, + rank INTEGER NOT NULL, + calculated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + period TEXT NOT NULL DEFAULT 'daily', -- 'hourly', 'daily', 'weekly' + post_count_in_period INTEGER NOT NULL DEFAULT 0, + UNIQUE(hashtag_id, period) +); + +-- Suggested users for discover page +CREATE TABLE IF NOT EXISTS suggested_users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + reason TEXT NOT NULL, -- 'popular', 'category', 'similar', 'new_creator' + category TEXT, + score FLOAT NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(user_id, reason) +); + +-- Indexes for performance +CREATE INDEX IF NOT EXISTS idx_hashtags_name ON hashtags(name); +CREATE INDEX IF NOT EXISTS idx_hashtags_trending ON hashtags(is_trending, trending_score DESC); +CREATE INDEX IF NOT EXISTS idx_hashtags_use_count ON hashtags(use_count DESC); +CREATE INDEX IF NOT EXISTS idx_post_hashtags_hashtag ON post_hashtags(hashtag_id); +CREATE INDEX IF NOT EXISTS idx_post_mentions_user ON post_mentions(mentioned_user_id); +CREATE INDEX IF NOT EXISTS idx_trending_hashtags_rank ON trending_hashtags(period, rank); +CREATE INDEX IF NOT EXISTS idx_suggested_users_active ON suggested_users(is_active, score DESC); + +-- Function to update hashtag use count +CREATE OR REPLACE FUNCTION update_hashtag_count() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + UPDATE hashtags SET use_count = use_count + 1, updated_at = NOW() WHERE id = NEW.hashtag_id; + ELSIF TG_OP = 'DELETE' THEN + UPDATE hashtags SET use_count = GREATEST(0, use_count - 1), updated_at = NOW() WHERE id = OLD.hashtag_id; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS hashtag_count_trigger ON post_hashtags; +CREATE TRIGGER hashtag_count_trigger + AFTER INSERT OR DELETE ON post_hashtags + FOR EACH ROW + EXECUTE FUNCTION update_hashtag_count(); + +-- Function to calculate trending score (decay-based) +CREATE OR REPLACE FUNCTION calculate_trending_scores() +RETURNS void AS $$ +DECLARE + decay_factor FLOAT := 0.95; + hours_window INTEGER := 24; +BEGIN + -- Calculate trending score based on recent usage with time decay + UPDATE hashtags h + SET + trending_score = COALESCE(( + SELECT SUM(POWER(decay_factor, EXTRACT(EPOCH FROM (NOW() - ph.created_at)) / 3600)) + FROM post_hashtags ph + WHERE ph.hashtag_id = h.id + AND ph.created_at > NOW() - INTERVAL '24 hours' + ), 0), + is_trending = ( + SELECT COUNT(*) >= 5 + FROM post_hashtags ph + WHERE ph.hashtag_id = h.id + AND ph.created_at > NOW() - INTERVAL '24 hours' + ), + updated_at = NOW(); + + -- Update trending_hashtags table + DELETE FROM trending_hashtags WHERE period = 'daily'; + INSERT INTO trending_hashtags (hashtag_id, rank, period, post_count_in_period) + SELECT + h.id, + ROW_NUMBER() OVER (ORDER BY h.trending_score DESC), + 'daily', + (SELECT COUNT(*) FROM post_hashtags ph WHERE ph.hashtag_id = h.id AND ph.created_at > NOW() - INTERVAL '24 hours') + FROM hashtags h + WHERE h.trending_score > 0 + ORDER BY h.trending_score DESC + LIMIT 50; +END; +$$ LANGUAGE plpgsql; diff --git a/go-backend/internal/database/migrations/000012_privacy_settings.up.sql b/go-backend/internal/database/migrations/000012_privacy_settings.up.sql new file mode 100644 index 0000000..c9a50d7 --- /dev/null +++ b/go-backend/internal/database/migrations/000012_privacy_settings.up.sql @@ -0,0 +1,13 @@ +-- Add privacy settings to profiles +ALTER TABLE public.profiles +ADD COLUMN IF NOT EXISTS default_post_visibility TEXT NOT NULL DEFAULT 'public', +ADD COLUMN IF NOT EXISTS is_private_profile BOOLEAN NOT NULL DEFAULT FALSE; + +-- Create table for blocked users +CREATE TABLE IF NOT EXISTS public.blocks ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + blocker_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE, + blocked_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(blocker_id, blocked_id) +); diff --git a/go-backend/internal/database/migrations/000013_privacy_refinement.up.sql b/go-backend/internal/database/migrations/000013_privacy_refinement.up.sql new file mode 100644 index 0000000..86719fc --- /dev/null +++ b/go-backend/internal/database/migrations/000013_privacy_refinement.up.sql @@ -0,0 +1,13 @@ +-- Add missing columns to profile_privacy_settings +ALTER TABLE public.profile_privacy_settings +ADD COLUMN IF NOT EXISTS default_post_visibility TEXT NOT NULL DEFAULT 'public', +ADD COLUMN IF NOT EXISTS is_private_profile BOOLEAN NOT NULL DEFAULT FALSE; + +-- Ensure blocks table exists +CREATE TABLE IF NOT EXISTS public.blocks ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + blocker_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE, + blocked_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(blocker_id, blocked_id) +); diff --git a/go-backend/internal/database/migrations/000014_structural_moderation.up.sql b/go-backend/internal/database/migrations/000014_structural_moderation.up.sql new file mode 100644 index 0000000..422be9c --- /dev/null +++ b/go-backend/internal/database/migrations/000014_structural_moderation.up.sql @@ -0,0 +1,60 @@ +-- Structural Blocking & Abuse Tracking + +-- Create abuse_logs table +CREATE TABLE IF NOT EXISTS public.abuse_logs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + actor_id UUID REFERENCES public.profiles(id) ON DELETE SET NULL, + blocked_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE, + blocked_handle TEXT NOT NULL, + actor_ip TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Create reports table +CREATE TABLE IF NOT EXISTS public.reports ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + reporter_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE, + target_user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE, + post_id UUID REFERENCES public.posts(id) ON DELETE SET NULL, + comment_id UUID REFERENCES public.comments(id) ON DELETE SET NULL, + violation_type TEXT NOT NULL, -- e.g., 'hate', 'greed', 'delusion' + description TEXT, + status TEXT NOT NULL DEFAULT 'pending', -- pending, reviewed, resolved + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Create pending_moderation table for AI flags +CREATE TABLE IF NOT EXISTS public.pending_moderation ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + post_id UUID REFERENCES public.posts(id) ON DELETE CASCADE, + comment_id UUID REFERENCES public.comments(id) ON DELETE CASCADE, + flag_reason TEXT NOT NULL, + scores JSONB, -- store AI scores for 'hate', 'greed', 'delusion' + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Blocking Function +CREATE OR REPLACE FUNCTION public.has_block_between(user_a UUID, user_b UUID) +RETURNS BOOLEAN AS $$ +BEGIN + RETURN EXISTS ( + SELECT 1 FROM public.blocks + WHERE (blocker_id = user_a AND blocked_id = user_b) + OR (blocker_id = user_b AND blocked_id = user_a) + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Note: RLS implementation typically requires the database to be aware of the "current_user_id". +-- Since this is an external Go API, we will enforce structural invisibility in the Repositories/Queries +-- for now if RLS isn't fully configured with the Auth provider's claims. +-- However, we'll set up the policies anyway for parity. + +-- Enable RLS +ALTER TABLE public.posts ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.comments ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY; + +-- Post Policy: Hide posts from/to blocked users +-- This assuming jwt.claims.sub can be passed to session configs +-- For now, we will focus on SQL Query enforcement in Repo layer which is more reliable for custom Go Backends diff --git a/go-backend/internal/handlers/discover_handler.go b/go-backend/internal/handlers/discover_handler.go new file mode 100644 index 0000000..c39e493 --- /dev/null +++ b/go-backend/internal/handlers/discover_handler.go @@ -0,0 +1,367 @@ +package handlers + +import ( + "net/http" + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/patbritton/sojorn-backend/internal/models" + "github.com/patbritton/sojorn-backend/internal/repository" + "github.com/patbritton/sojorn-backend/internal/services" + "github.com/patbritton/sojorn-backend/pkg/utils" + "github.com/rs/zerolog/log" +) + +type DiscoverHandler struct { + userRepo *repository.UserRepository + postRepo *repository.PostRepository + tagRepo *repository.TagRepository + categoryRepo repository.CategoryRepository + assetService *services.AssetService +} + +func NewDiscoverHandler( + userRepo *repository.UserRepository, + postRepo *repository.PostRepository, + tagRepo *repository.TagRepository, + categoryRepo repository.CategoryRepository, + assetService *services.AssetService, +) *DiscoverHandler { + return &DiscoverHandler{ + userRepo: userRepo, + postRepo: postRepo, + tagRepo: tagRepo, + categoryRepo: categoryRepo, + assetService: assetService, + } +} + +// GetDiscover returns the discover page data +// GET /api/v1/discover +func (h *DiscoverHandler) GetDiscover(c *gin.Context) { + userIDStr, _ := c.Get("user_id") + userID := userIDStr.(string) + + ctx := c.Request.Context() + start := time.Now() + + var wg sync.WaitGroup + var trending []models.Hashtag + var popularPosts []models.Post + + wg.Add(2) + + // Get top 4 tags + go func() { + defer wg.Done() + var err error + trending, err = h.tagRepo.GetTrendingHashtags(ctx, 4) + if err != nil { + log.Warn().Err(err).Msg("Failed to get trending hashtags") + } + }() + + // Get popular public posts + go func() { + defer wg.Done() + var err error + popularPosts, err = h.postRepo.GetPopularPublicPosts(ctx, userID, 20) + if err != nil { + log.Warn().Err(err).Msg("Failed to get popular posts") + } + }() + + wg.Wait() + + // Sign URLs + for i := range popularPosts { + h.signPostURLs(&popularPosts[i]) + } + + // Ensure non-nil slices + if trending == nil { + trending = []models.Hashtag{} + } + if popularPosts == nil { + popularPosts = []models.Post{} + } + + response := gin.H{ + "top_tags": trending, + "popular_posts": popularPosts, + } + + log.Debug().Dur("duration", time.Since(start)).Msg("Discover page generated") + c.JSON(http.StatusOK, response) +} + +// Search performs a combined search across users, hashtags, and posts +// GET /api/v1/search +func (h *DiscoverHandler) Search(c *gin.Context) { + query := c.Query("q") + searchType := c.Query("type") // "all", "users", "hashtags", "posts" + + if query == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Query parameter 'q' is required"}) + return + } + + if searchType == "" { + searchType = "all" + } + + ctx := c.Request.Context() + start := time.Now() + + viewerID := "" + if val, exists := c.Get("user_id"); exists { + viewerID = val.(string) + } + + var wg sync.WaitGroup + var users []models.Profile + var hashtags []models.Hashtag + var posts []models.Post + var tags []models.TagResult + + if searchType == "all" || searchType == "users" { + wg.Add(1) + go func() { + defer wg.Done() + var err error + users, err = h.userRepo.SearchUsers(ctx, query, 10) + if err != nil { + log.Warn().Err(err).Msg("Failed to search users") + users = []models.Profile{} + } + }() + } + + if searchType == "all" || searchType == "hashtags" { + wg.Add(1) + go func() { + defer wg.Done() + var err error + hashtags, err = h.tagRepo.SearchHashtags(ctx, query, 10) + if err != nil { + log.Warn().Err(err).Msg("Failed to search hashtags") + hashtags = []models.Hashtag{} + } + // Also get legacy tag results for backward compatibility + tags, _ = h.postRepo.SearchTags(ctx, query, 5) + }() + } + + if searchType == "all" || searchType == "posts" { + wg.Add(1) + go func() { + defer wg.Done() + var err error + posts, err = h.postRepo.SearchPosts(ctx, query, viewerID, 20) + if err != nil { + log.Warn().Err(err).Msg("Failed to search posts") + posts = []models.Post{} + } + }() + } + + wg.Wait() + + // Sign URLs + for i := range users { + if users[i].AvatarURL != nil { + signed := h.assetService.SignImageURL(*users[i].AvatarURL) + users[i].AvatarURL = &signed + } + } + for i := range posts { + h.signPostURLs(&posts[i]) + } + + // Ensure non-nil slices + if users == nil { + users = []models.Profile{} + } + if hashtags == nil { + hashtags = []models.Hashtag{} + } + if tags == nil { + tags = []models.TagResult{} + } + if posts == nil { + posts = []models.Post{} + } + + response := gin.H{ + "users": users, + "hashtags": hashtags, + "tags": tags, // Legacy format + "posts": posts, + "query": query, + } + + log.Info().Str("query", query).Str("type", searchType).Dur("duration", time.Since(start)).Msg("Search completed") + c.JSON(http.StatusOK, response) +} + +// GetHashtagPage returns posts for a specific hashtag +// GET /api/v1/hashtags/:name +func (h *DiscoverHandler) GetHashtagPage(c *gin.Context) { + hashtagName := c.Param("name") + if hashtagName == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Hashtag name required"}) + return + } + + ctx := c.Request.Context() + limit := utils.GetQueryInt(c, "limit", 20) + offset := utils.GetQueryInt(c, "offset", 0) + + viewerID := "" + if val, exists := c.Get("user_id"); exists { + viewerID = val.(string) + } + + // Get hashtag info + hashtag, err := h.tagRepo.GetHashtagByName(ctx, hashtagName) + if err != nil || hashtag == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Hashtag not found"}) + return + } + + // Check if user follows this hashtag + isFollowing := false + if viewerID != "" { + userUUID, err := uuid.Parse(viewerID) + if err == nil { + isFollowing, _ = h.tagRepo.IsFollowingHashtag(ctx, userUUID, hashtag.ID) + } + } + + // Get posts + posts, err := h.tagRepo.GetPostsByHashtag(ctx, hashtagName, viewerID, limit, offset) + if err != nil { + log.Error().Err(err).Str("hashtag", hashtagName).Msg("Failed to get posts by hashtag") + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch posts"}) + return + } + + // Sign URLs + for i := range posts { + h.signPostURLs(&posts[i]) + } + + if posts == nil { + posts = []models.Post{} + } + + response := models.HashtagPageResponse{ + Hashtag: *hashtag, + Posts: posts, + IsFollowing: isFollowing, + TotalPosts: hashtag.UseCount, + } + + c.JSON(http.StatusOK, response) +} + +// FollowHashtag follows a hashtag +// POST /api/v1/hashtags/:name/follow +func (h *DiscoverHandler) FollowHashtag(c *gin.Context) { + hashtagName := c.Param("name") + userIDStr, _ := c.Get("user_id") + userUUID, _ := uuid.Parse(userIDStr.(string)) + + ctx := c.Request.Context() + + // Get or create the hashtag + hashtag, err := h.tagRepo.GetOrCreateHashtag(ctx, hashtagName) + if err != nil || hashtag == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process hashtag"}) + return + } + + if err := h.tagRepo.FollowHashtag(ctx, userUUID, hashtag.ID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to follow hashtag"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Now following #" + hashtag.Name}) +} + +// UnfollowHashtag unfollows a hashtag +// DELETE /api/v1/hashtags/:name/follow +func (h *DiscoverHandler) UnfollowHashtag(c *gin.Context) { + hashtagName := c.Param("name") + userIDStr, _ := c.Get("user_id") + userUUID, _ := uuid.Parse(userIDStr.(string)) + + ctx := c.Request.Context() + + hashtag, err := h.tagRepo.GetHashtagByName(ctx, hashtagName) + if err != nil || hashtag == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Hashtag not found"}) + return + } + + if err := h.tagRepo.UnfollowHashtag(ctx, userUUID, hashtag.ID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unfollow hashtag"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Unfollowed #" + hashtag.Name}) +} + +// GetTrendingHashtags returns trending hashtags +// GET /api/v1/hashtags/trending +func (h *DiscoverHandler) GetTrendingHashtags(c *gin.Context) { + limit := utils.GetQueryInt(c, "limit", 20) + + hashtags, err := h.tagRepo.GetTrendingHashtags(c.Request.Context(), limit) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch trending hashtags"}) + return + } + + if hashtags == nil { + hashtags = []models.Hashtag{} + } + + c.JSON(http.StatusOK, gin.H{"hashtags": hashtags}) +} + +// GetFollowedHashtags returns hashtags the user follows +// GET /api/v1/hashtags/following +func (h *DiscoverHandler) GetFollowedHashtags(c *gin.Context) { + userIDStr, _ := c.Get("user_id") + userUUID, _ := uuid.Parse(userIDStr.(string)) + + hashtags, err := h.tagRepo.GetFollowedHashtags(c.Request.Context(), userUUID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch followed hashtags"}) + return + } + + if hashtags == nil { + hashtags = []models.Hashtag{} + } + + c.JSON(http.StatusOK, gin.H{"hashtags": hashtags}) +} + +// signPostURLs signs all URLs in a post +func (h *DiscoverHandler) signPostURLs(post *models.Post) { + if post.ImageURL != nil { + signed := h.assetService.SignImageURL(*post.ImageURL) + post.ImageURL = &signed + } + if post.VideoURL != nil { + signed := h.assetService.SignVideoURL(*post.VideoURL) + post.VideoURL = &signed + } + if post.Author != nil && post.Author.AvatarURL != "" { + post.Author.AvatarURL = h.assetService.SignImageURL(post.Author.AvatarURL) + } +} diff --git a/go-backend/internal/handlers/notification_handler.go b/go-backend/internal/handlers/notification_handler.go index 01954e1..42df9dd 100644 --- a/go-backend/internal/handlers/notification_handler.go +++ b/go-backend/internal/handlers/notification_handler.go @@ -2,46 +2,281 @@ package handlers import ( "net/http" - "strconv" "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/patbritton/sojorn-backend/internal/models" "github.com/patbritton/sojorn-backend/internal/repository" + "github.com/patbritton/sojorn-backend/internal/services" + "github.com/patbritton/sojorn-backend/pkg/utils" + "github.com/rs/zerolog/log" ) type NotificationHandler struct { - repo *repository.NotificationRepository + notifRepo *repository.NotificationRepository + notifService *services.NotificationService } -func NewNotificationHandler(repo *repository.NotificationRepository) *NotificationHandler { - return &NotificationHandler{repo: repo} +func NewNotificationHandler(notifRepo *repository.NotificationRepository, notifService *services.NotificationService) *NotificationHandler { + return &NotificationHandler{ + notifRepo: notifRepo, + notifService: notifService, + } } +// GetNotifications retrieves paginated notifications for the user +// GET /api/v1/notifications func (h *NotificationHandler) GetNotifications(c *gin.Context) { - userIdStr, exists := c.Get("user_id") + userIDStr, exists := c.Get("user_id") if !exists { c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) return } - limit := 20 - offset := 0 + limit := utils.GetQueryInt(c, "limit", 20) + offset := utils.GetQueryInt(c, "offset", 0) + grouped := c.Query("grouped") == "true" - if l := c.Query("limit"); l != "" { - if val, err := strconv.Atoi(l); err == nil { - limit = val - } - } - if o := c.Query("offset"); o != "" { - if val, err := strconv.Atoi(o); err == nil { - offset = val - } + var notifications []models.Notification + var err error + + if grouped { + notifications, err = h.notifRepo.GetGroupedNotifications(c.Request.Context(), userIDStr.(string), limit, offset) + } else { + notifications, err = h.notifRepo.GetNotifications(c.Request.Context(), userIDStr.(string), limit, offset) } - notifications, err := h.repo.GetNotifications(c.Request.Context(), userIdStr.(string), limit, offset) if err != nil { + log.Error().Err(err).Msg("Failed to fetch notifications") c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch notifications"}) return } - c.JSON(http.StatusOK, notifications) + c.JSON(http.StatusOK, gin.H{"notifications": notifications}) +} + +// GetUnreadCount returns the unread notification count +// GET /api/v1/notifications/unread +func (h *NotificationHandler) GetUnreadCount(c *gin.Context) { + userIDStr, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + count, err := h.notifRepo.GetUnreadCount(c.Request.Context(), userIDStr.(string)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch unread count"}) + return + } + + c.JSON(http.StatusOK, gin.H{"count": count}) +} + +// GetBadgeCount returns the badge count for app icon badges +// GET /api/v1/notifications/badge +func (h *NotificationHandler) GetBadgeCount(c *gin.Context) { + userIDStr, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + badge, err := h.notifRepo.GetUnreadBadge(c.Request.Context(), userIDStr.(string)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch badge count"}) + return + } + + c.JSON(http.StatusOK, badge) +} + +// MarkAsRead marks a single notification as read +// PUT /api/v1/notifications/:id/read +func (h *NotificationHandler) MarkAsRead(c *gin.Context) { + userIDStr, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + notificationID := c.Param("id") + if _, err := uuid.Parse(notificationID); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid notification ID"}) + return + } + + err := h.notifRepo.MarkAsRead(c.Request.Context(), notificationID, userIDStr.(string)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to mark notification as read"}) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +// MarkAllAsRead marks all notifications as read +// PUT /api/v1/notifications/read-all +func (h *NotificationHandler) MarkAllAsRead(c *gin.Context) { + userIDStr, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + err := h.notifRepo.MarkAllAsRead(c.Request.Context(), userIDStr.(string)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to mark all as read"}) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +// DeleteNotification deletes a notification +// DELETE /api/v1/notifications/:id +func (h *NotificationHandler) DeleteNotification(c *gin.Context) { + userIDStr, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + notificationID := c.Param("id") + if _, err := uuid.Parse(notificationID); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid notification ID"}) + return + } + + err := h.notifRepo.DeleteNotification(c.Request.Context(), notificationID, userIDStr.(string)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete notification"}) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +// GetNotificationPreferences returns the user's notification preferences +// GET /api/v1/notifications/preferences +func (h *NotificationHandler) GetNotificationPreferences(c *gin.Context) { + userIDStr, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + prefs, err := h.notifRepo.GetNotificationPreferences(c.Request.Context(), userIDStr.(string)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch preferences"}) + return + } + + c.JSON(http.StatusOK, prefs) +} + +// UpdateNotificationPreferences updates the user's notification preferences +// PUT /api/v1/notifications/preferences +func (h *NotificationHandler) UpdateNotificationPreferences(c *gin.Context) { + userIDStr, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + userID, _ := uuid.Parse(userIDStr.(string)) + + var prefs models.NotificationPreferences + if err := c.ShouldBindJSON(&prefs); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + prefs.UserID = userID + + if err := h.notifRepo.UpdateNotificationPreferences(c.Request.Context(), &prefs); err != nil { + log.Error().Err(err).Msg("Failed to update notification preferences") + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update preferences"}) + return + } + + c.JSON(http.StatusOK, prefs) +} + +// RegisterDevice registers an FCM token for push notifications +// POST /api/v1/notifications/device +func (h *NotificationHandler) RegisterDevice(c *gin.Context) { + userIDStr, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + userID, _ := uuid.Parse(userIDStr.(string)) + + var req models.UserFCMToken + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + req.UserID = userID + + if err := h.notifRepo.UpsertFCMToken(c.Request.Context(), &req); err != nil { + log.Error().Err(err).Msg("Failed to register device") + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to register device"}) + return + } + + log.Info(). + Str("user_id", userID.String()). + Str("platform", req.Platform). + Msg("FCM token registered") + + c.JSON(http.StatusOK, gin.H{"message": "Device registered"}) +} + +// UnregisterDevice removes an FCM token +// DELETE /api/v1/notifications/device +func (h *NotificationHandler) UnregisterDevice(c *gin.Context) { + userIDStr, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + var req struct { + FCMToken string `json:"fcm_token" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + if err := h.notifRepo.DeleteFCMToken(c.Request.Context(), userIDStr.(string), req.FCMToken); err != nil { + log.Error().Err(err).Msg("Failed to unregister device") + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unregister device"}) + return + } + + log.Info(). + Str("user_id", userIDStr.(string)). + Msg("FCM token unregistered") + + c.JSON(http.StatusOK, gin.H{"message": "Device unregistered"}) +} + +// UnregisterAllDevices removes all FCM tokens for the user (logout from all devices) +// DELETE /api/v1/notifications/devices +func (h *NotificationHandler) UnregisterAllDevices(c *gin.Context) { + userIDStr, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + if err := h.notifRepo.DeleteAllFCMTokensForUser(c.Request.Context(), userIDStr.(string)); err != nil { + log.Error().Err(err).Msg("Failed to unregister all devices") + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unregister devices"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "All devices unregistered"}) } diff --git a/go-backend/internal/handlers/post_handler.go b/go-backend/internal/handlers/post_handler.go index 652f09e..4850115 100644 --- a/go-backend/internal/handlers/post_handler.go +++ b/go-backend/internal/handlers/post_handler.go @@ -20,15 +20,17 @@ type PostHandler struct { feedService *services.FeedService assetService *services.AssetService notificationService *services.NotificationService + moderationService *services.ModerationService } -func NewPostHandler(postRepo *repository.PostRepository, userRepo *repository.UserRepository, feedService *services.FeedService, assetService *services.AssetService, notificationService *services.NotificationService) *PostHandler { +func NewPostHandler(postRepo *repository.PostRepository, userRepo *repository.UserRepository, feedService *services.FeedService, assetService *services.AssetService, notificationService *services.NotificationService, moderationService *services.ModerationService) *PostHandler { return &PostHandler{ postRepo: postRepo, userRepo: userRepo, feedService: feedService, assetService: assetService, notificationService: notificationService, + moderationService: moderationService, } } @@ -236,6 +238,22 @@ func (h *PostHandler) CreatePost(c *gin.Context) { } } + // 5. AI Moderation Check + if h.moderationService != nil { + scores, reason, err := h.moderationService.AnalyzeContent(c.Request.Context(), req.Body) + if err == nil { + cis = (scores.Hate + scores.Greed + scores.Delusion) / 3.0 + cis = 1.0 - cis // Invert so 1.0 is pure, 0.0 is toxic + post.CISScore = &cis + post.ToneLabel = &reason + + if reason != "" { + // Flag if any poison is detected + post.Status = "pending_moderation" + } + } + } + // Create post err = h.postRepo.CreatePost(c.Request.Context(), post) if err != nil { @@ -243,6 +261,20 @@ func (h *PostHandler) CreatePost(c *gin.Context) { return } + // Handle Flags + if h.moderationService != nil && post.Status == "pending_moderation" { + scores, reason, _ := h.moderationService.AnalyzeContent(c.Request.Context(), req.Body) + _ = h.moderationService.FlagPost(c.Request.Context(), post.ID, scores, reason) + } + + // Check for @mentions and notify mentioned users + go func() { + if h.notificationService != nil && strings.Contains(req.Body, "@") { + postIDStr := post.ID.String() + h.notificationService.NotifyMention(c.Request.Context(), userIDStr.(string), postIDStr, req.Body) + } + }() + c.JSON(http.StatusCreated, gin.H{ "post": post, "tags": tags, @@ -409,6 +441,31 @@ func (h *PostHandler) LikePost(c *gin.Context) { return } + // Send push notification to post author + go func() { + post, err := h.postRepo.GetPostByID(c.Request.Context(), postID, userIDStr.(string)) + if err != nil || post.AuthorID.String() == userIDStr.(string) { + return // Don't notify self + } + + if h.notificationService != nil { + postType := "standard" + if post.IsBeacon { + postType = "beacon" + } else if post.VideoURL != nil && *post.VideoURL != "" { + postType = "quip" + } + + h.notificationService.NotifyLike( + c.Request.Context(), + post.AuthorID.String(), + userIDStr.(string), + postID, + postType, + ) + } + }() + c.JSON(http.StatusOK, gin.H{"message": "Post liked"}) } @@ -435,6 +492,42 @@ func (h *PostHandler) SavePost(c *gin.Context) { return } + // Send push notification to post author + go func() { + post, err := h.postRepo.GetPostByID(c.Request.Context(), postID, userIDStr.(string)) + if err != nil || post.AuthorID.String() == userIDStr.(string) { + return // Don't notify self + } + + actor, err := h.userRepo.GetProfileByID(c.Request.Context(), userIDStr.(string)) + if err != nil || h.notificationService == nil { + return + } + + // Determine post type for proper deep linking + postType := "standard" + if post.IsBeacon { + postType = "beacon" + } else if post.VideoURL != nil && *post.VideoURL != "" { + postType = "quip" + } + + metadata := map[string]interface{}{ + "actor_name": actor.DisplayName, + "post_id": postID, + "post_type": postType, + } + h.notificationService.CreateNotification( + c.Request.Context(), + post.AuthorID.String(), + userIDStr.(string), + "save", + &postID, + nil, + metadata, + ) + }() + c.JSON(http.StatusOK, gin.H{"message": "Post saved"}) } diff --git a/go-backend/internal/handlers/user_handler.go b/go-backend/internal/handlers/user_handler.go index e4de96c..a43595e 100644 --- a/go-backend/internal/handlers/user_handler.go +++ b/go-backend/internal/handlers/user_handler.go @@ -355,3 +355,156 @@ func (h *UserHandler) RejectFollowRequest(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "Follow request rejected"}) } + +func (h *UserHandler) BlockUser(c *gin.Context) { + blockerID, _ := c.Get("user_id") + blockedID := c.Param("id") + actorIP := c.ClientIP() + + if err := h.repo.BlockUser(c.Request.Context(), blockerID.(string), blockedID, actorIP); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to block user"}) + return + } + + // Also unfollow automatically + _ = h.repo.UnfollowUser(c.Request.Context(), blockerID.(string), blockedID) + _ = h.repo.UnfollowUser(c.Request.Context(), blockedID, blockerID.(string)) + + c.JSON(http.StatusOK, gin.H{"message": "User blocked"}) +} + +func (h *UserHandler) ReportUser(c *gin.Context) { + reporterID, _ := c.Get("user_id") + + var input struct { + TargetUserID string `json:"target_user_id" binding:"required"` + PostID string `json:"post_id"` + CommentID string `json:"comment_id"` + ViolationType string `json:"violation_type" binding:"required"` + Description string `json:"description"` + } + + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + rID, _ := uuid.Parse(reporterID.(string)) + tID, _ := uuid.Parse(input.TargetUserID) + + report := &models.Report{ + ReporterID: rID, + TargetUserID: tID, + ViolationType: input.ViolationType, + Description: input.Description, + } + + if input.PostID != "" { + pID, _ := uuid.Parse(input.PostID) + report.PostID = &pID + } + if input.CommentID != "" { + cID, _ := uuid.Parse(input.CommentID) + report.CommentID = &cID + } + + if err := h.repo.CreateReport(c.Request.Context(), report); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create report"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Report submitted successfully"}) +} + +func (h *UserHandler) UnblockUser(c *gin.Context) { + blockerID, _ := c.Get("user_id") + blockedID := c.Param("id") + + if err := h.repo.UnblockUser(c.Request.Context(), blockerID.(string), blockedID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unblock user"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "User unblocked"}) +} + +func (h *UserHandler) GetBlockedUsers(c *gin.Context) { + userID, _ := c.Get("user_id") + + blocked, err := h.repo.GetBlockedUsers(c.Request.Context(), userID.(string)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch blocked users"}) + return + } + + // Sign URLs + for i := range blocked { + if blocked[i].AvatarURL != nil { + signed := h.assetService.SignImageURL(*blocked[i].AvatarURL) + blocked[i].AvatarURL = &signed + } + } + + c.JSON(http.StatusOK, gin.H{"users": blocked}) +} + +func (h *UserHandler) RemoveFCMToken(c *gin.Context) { + userID, _ := c.Get("user_id") + + var input struct { + Token string `json:"token" binding:"required"` + } + + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Token is required"}) + return + } + + if err := h.repo.DeleteFCMToken(c.Request.Context(), userID.(string), input.Token); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove token"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Token removed successfully"}) +} +func (h *UserHandler) BlockUserByHandle(c *gin.Context) { + actorID, _ := c.Get("user_id") + actorIP := c.ClientIP() + + var input struct { + Handle string `json:"handle" binding:"required"` + } + + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Handle is required"}) + return + } + + if err := h.repo.BlockUserByHandle(c.Request.Context(), actorID.(string), input.Handle, actorIP); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to block user"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "User blocked by handle"}) +} + +func (h *UserHandler) RegisterFCMToken(c *gin.Context) { + userID, _ := c.Get("user_id") + + var input struct { + Token string `json:"token" binding:"required"` + Platform string `json:"platform" binding:"required"` + } + + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Token and platform are required"}) + return + } + + if err := h.repo.UpsertFCMToken(c.Request.Context(), userID.(string), input.Token, input.Platform); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to register token"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Token registered successfully"}) +} diff --git a/go-backend/internal/models/moderation.go b/go-backend/internal/models/moderation.go new file mode 100644 index 0000000..066cb8c --- /dev/null +++ b/go-backend/internal/models/moderation.go @@ -0,0 +1,37 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type Report struct { + ID uuid.UUID `json:"id"` + ReporterID uuid.UUID `json:"reporter_id"` + TargetUserID uuid.UUID `json:"target_user_id"` + PostID *uuid.UUID `json:"post_id,omitempty"` + CommentID *uuid.UUID `json:"comment_id,omitempty"` + ViolationType string `json:"violation_type"` + Description string `json:"description"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` +} + +type AbuseLog struct { + ID uuid.UUID `json:"id"` + ActorID *uuid.UUID `json:"actor_id,omitempty"` + BlockedID uuid.UUID `json:"blocked_id"` + BlockedHandle string `json:"blocked_handle"` + ActorIP string `json:"actor_ip"` + CreatedAt time.Time `json:"created_at"` +} + +type ModerationFlag struct { + ID uuid.UUID `json:"id"` + PostID *uuid.UUID `json:"post_id,omitempty"` + CommentID *uuid.UUID `json:"comment_id,omitempty"` + FlagReason string `json:"flag_reason"` + Scores map[string]float64 `json:"scores"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/go-backend/internal/models/notification.go b/go-backend/internal/models/notification.go index 2524ba3..cc264be 100644 --- a/go-backend/internal/models/notification.go +++ b/go-backend/internal/models/notification.go @@ -7,22 +7,53 @@ import ( "github.com/google/uuid" ) +// NotificationType constants for type safety +const ( + NotificationTypeLike = "like" + NotificationTypeComment = "comment" + NotificationTypeReply = "reply" + NotificationTypeMention = "mention" + NotificationTypeFollow = "follow" + NotificationTypeFollowRequest = "follow_request" + NotificationTypeFollowAccept = "follow_accepted" + NotificationTypeMessage = "message" + NotificationTypeSave = "save" + NotificationTypeBeaconVouch = "beacon_vouch" + NotificationTypeBeaconReport = "beacon_report" + NotificationTypeShare = "share" + NotificationTypeQuipReaction = "quip_reaction" +) + +// NotificationPriority constants +const ( + PriorityLow = "low" + PriorityNormal = "normal" + PriorityHigh = "high" + PriorityUrgent = "urgent" +) + type Notification struct { ID uuid.UUID `json:"id" db:"id"` UserID uuid.UUID `json:"user_id" db:"user_id"` - Type string `json:"type" db:"type"` // like, comment, follow, reply, mention + Type string `json:"type" db:"type"` ActorID uuid.UUID `json:"actor_id" db:"actor_id"` PostID *uuid.UUID `json:"post_id,omitempty" db:"post_id"` CommentID *uuid.UUID `json:"comment_id,omitempty" db:"comment_id"` IsRead bool `json:"is_read" db:"is_read"` CreatedAt time.Time `json:"created_at" db:"created_at"` Metadata json.RawMessage `json:"metadata" db:"metadata"` + GroupKey *string `json:"group_key,omitempty" db:"group_key"` + Priority string `json:"priority" db:"priority"` - // Joined fields + // Joined fields for display ActorHandle string `json:"actor_handle" db:"actor_handle"` ActorDisplayName string `json:"actor_display_name" db:"actor_display_name"` ActorAvatarURL string `json:"actor_avatar_url" db:"actor_avatar_url"` - PostImageURL *string `json:"post_image_url,omitempty" db:"post_image_url"` // Preview of post + PostImageURL *string `json:"post_image_url,omitempty" db:"post_image_url"` + PostBody *string `json:"post_body,omitempty" db:"post_body"` + + // For grouped notifications + GroupCount int `json:"group_count,omitempty" db:"group_count"` } type UserFCMToken struct { @@ -32,3 +63,68 @@ type UserFCMToken struct { CreatedAt time.Time `json:"created_at" db:"created_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"` } + +type NotificationPreferences struct { + UserID uuid.UUID `json:"user_id" db:"user_id"` + + // Push toggles + PushEnabled bool `json:"push_enabled" db:"push_enabled"` + PushLikes bool `json:"push_likes" db:"push_likes"` + PushComments bool `json:"push_comments" db:"push_comments"` + PushReplies bool `json:"push_replies" db:"push_replies"` + PushMentions bool `json:"push_mentions" db:"push_mentions"` + PushFollows bool `json:"push_follows" db:"push_follows"` + PushFollowRequests bool `json:"push_follow_requests" db:"push_follow_requests"` + PushMessages bool `json:"push_messages" db:"push_messages"` + PushSaves bool `json:"push_saves" db:"push_saves"` + PushBeacons bool `json:"push_beacons" db:"push_beacons"` + + // Email toggles + EmailEnabled bool `json:"email_enabled" db:"email_enabled"` + EmailDigestFrequency string `json:"email_digest_frequency" db:"email_digest_frequency"` + + // Quiet hours + QuietHoursEnabled bool `json:"quiet_hours_enabled" db:"quiet_hours_enabled"` + QuietHoursStart *string `json:"quiet_hours_start,omitempty" db:"quiet_hours_start"` // "22:00:00" + QuietHoursEnd *string `json:"quiet_hours_end,omitempty" db:"quiet_hours_end"` // "08:00:00" + + // Badge + ShowBadgeCount bool `json:"show_badge_count" db:"show_badge_count"` + + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// NotificationPayload is the structure sent to FCM +type NotificationPayload struct { + Title string `json:"title"` + Body string `json:"body"` + ImageURL string `json:"image_url,omitempty"` + Data map[string]string `json:"data"` + Priority string `json:"priority"` + Badge int `json:"badge,omitempty"` +} + +// PushNotificationRequest for internal use +type PushNotificationRequest struct { + UserID uuid.UUID + Type string + ActorID uuid.UUID + ActorName string + ActorAvatar string + PostID *uuid.UUID + CommentID *uuid.UUID + PostType string // "standard", "quip", "beacon" + PostPreview string // First ~50 chars of post body + PostImageURL string + GroupKey string + Priority string + Metadata map[string]interface{} +} + +// UnreadBadge for badge count responses +type UnreadBadge struct { + NotificationCount int `json:"notification_count"` + MessageCount int `json:"message_count"` + TotalCount int `json:"total_count"` +} diff --git a/go-backend/internal/models/settings.go b/go-backend/internal/models/settings.go index 36fdc39..ccf7dc2 100644 --- a/go-backend/internal/models/settings.go +++ b/go-backend/internal/models/settings.go @@ -7,14 +7,16 @@ import ( ) type PrivacySettings struct { - UserID uuid.UUID `json:"user_id" db:"user_id"` - ShowLocation *bool `json:"show_location" db:"show_location"` - ShowInterests *bool `json:"show_interests" db:"show_interests"` - ProfileVisibility *string `json:"profile_visibility" db:"profile_visibility"` - PostsVisibility *string `json:"posts_visibility" db:"posts_visibility"` - SavedVisibility *string `json:"saved_visibility" db:"saved_visibility"` - FollowRequestPolicy *string `json:"follow_request_policy" db:"follow_request_policy"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + UserID uuid.UUID `json:"user_id" db:"user_id"` + ShowLocation *bool `json:"show_location" db:"show_location"` + ShowInterests *bool `json:"show_interests" db:"show_interests"` + ProfileVisibility *string `json:"profile_visibility" db:"profile_visibility"` + PostsVisibility *string `json:"posts_visibility" db:"posts_visibility"` + SavedVisibility *string `json:"saved_visibility" db:"saved_visibility"` + FollowRequestPolicy *string `json:"follow_request_policy" db:"follow_request_policy"` + DefaultPostVisibility *string `json:"default_post_visibility" db:"default_post_visibility"` + IsPrivateProfile *bool `json:"is_private_profile" db:"is_private_profile"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` } type UserSettings struct { diff --git a/go-backend/internal/models/tag.go b/go-backend/internal/models/tag.go new file mode 100644 index 0000000..e8cea19 --- /dev/null +++ b/go-backend/internal/models/tag.go @@ -0,0 +1,90 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +// Hashtag represents a hashtag in the system +type Hashtag struct { + ID uuid.UUID `json:"id" db:"id"` + Name string `json:"name" db:"name"` // lowercase, without # + DisplayName string `json:"display_name" db:"display_name"` // original casing + UseCount int `json:"use_count" db:"use_count"` + TrendingScore float64 `json:"trending_score" db:"trending_score"` + IsTrending bool `json:"is_trending" db:"is_trending"` + IsFeatured bool `json:"is_featured" db:"is_featured"` + Category *string `json:"category,omitempty" db:"category"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + + // Computed fields + RecentCount int `json:"recent_count,omitempty" db:"-"` + IsFollowing bool `json:"is_following,omitempty" db:"-"` +} + +// NOTE: TagResult is defined in post.go + +// PostMention represents an @mention in a post +type PostMention struct { + ID uuid.UUID `json:"id" db:"id"` + PostID uuid.UUID `json:"post_id" db:"post_id"` + MentionedUserID uuid.UUID `json:"mentioned_user_id" db:"mentioned_user_id"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// HashtagFollow represents a user following a hashtag +type HashtagFollow struct { + UserID uuid.UUID `json:"user_id" db:"user_id"` + HashtagID uuid.UUID `json:"hashtag_id" db:"hashtag_id"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// SuggestedUser for discover page +type SuggestedUser struct { + ID uuid.UUID `json:"id"` + Handle string `json:"handle"` + DisplayName *string `json:"display_name,omitempty"` + AvatarURL *string `json:"avatar_url,omitempty"` + Bio *string `json:"bio,omitempty"` + IsVerified bool `json:"is_verified"` + IsOfficial bool `json:"is_official"` + Reason string `json:"reason"` // e.g., "popular", "similar", "category" + Category *string `json:"category,omitempty"` + Score float64 `json:"score"` + FollowerCount int `json:"follower_count"` +} + +// TrendingHashtag for trending display +type TrendingHashtag struct { + Hashtag + Rank int `json:"rank" db:"rank"` + PostCountInPeriod int `json:"post_count_in_period" db:"post_count_in_period"` +} + +// DiscoverSection represents a section in the discover page +type DiscoverSection struct { + Title string `json:"title"` + Type string `json:"type"` // "hashtags", "users", "posts", "categories" + Items interface{} `json:"items"` + ViewAllID string `json:"view_all_id,omitempty"` // ID or slug for "View All" link +} + +// DiscoverResponse is the full discover page response +type DiscoverResponse struct { + TrendingHashtags []Hashtag `json:"trending_hashtags"` + FeaturedHashtags []Hashtag `json:"featured_hashtags,omitempty"` + SuggestedUsers []SuggestedUser `json:"suggested_users"` + PopularCreators []Profile `json:"popular_creators,omitempty"` + TrendingPosts []Post `json:"trending_posts,omitempty"` + FollowedHashtags []Hashtag `json:"followed_hashtags,omitempty"` + Categories []Category `json:"categories,omitempty"` +} + +// HashtagPageResponse for viewing a specific hashtag +type HashtagPageResponse struct { + Hashtag Hashtag `json:"hashtag"` + Posts []Post `json:"posts"` + IsFollowing bool `json:"is_following"` + TotalPosts int `json:"total_posts"` +} diff --git a/go-backend/internal/models/user.go b/go-backend/internal/models/user.go index d88fa64..b955687 100644 --- a/go-backend/internal/models/user.go +++ b/go-backend/internal/models/user.go @@ -35,6 +35,7 @@ type Profile struct { CoverURL *string `json:"cover_url" db:"cover_url"` IsOfficial *bool `json:"is_official" db:"is_official"` IsPrivate *bool `json:"is_private" db:"is_private"` + IsVerified *bool `json:"is_verified" db:"is_verified"` BeaconEnabled bool `json:"beacon_enabled" db:"beacon_enabled"` Location *string `json:"location" db:"location"` Website *string `json:"website" db:"website"` @@ -48,6 +49,10 @@ type Profile struct { Role string `json:"role" db:"role"` CreatedAt time.Time `json:"created_at" db:"created_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + + // Computed fields (not stored in DB) + FollowerCount *int `json:"follower_count,omitempty" db:"-"` + FollowingCount *int `json:"following_count,omitempty" db:"-"` } type Follow struct { diff --git a/go-backend/internal/repository/notification_repository.go b/go-backend/internal/repository/notification_repository.go index 26763e4..c795f80 100644 --- a/go-backend/internal/repository/notification_repository.go +++ b/go-backend/internal/repository/notification_repository.go @@ -2,10 +2,13 @@ package repository import ( "context" + "encoding/json" "time" + "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" "github.com/patbritton/sojorn-backend/internal/models" + "github.com/rs/zerolog/log" ) type NotificationRepository struct { @@ -16,6 +19,10 @@ func NewNotificationRepository(pool *pgxpool.Pool) *NotificationRepository { return &NotificationRepository{pool: pool} } +// ============================================================================ +// FCM Token Management +// ============================================================================ + func (r *NotificationRepository) UpsertFCMToken(ctx context.Context, token *models.UserFCMToken) error { query := ` INSERT INTO public.user_fcm_tokens (user_id, token, device_type, created_at, last_updated) @@ -60,25 +67,52 @@ func (r *NotificationRepository) GetFCMTokensForUser(ctx context.Context, userID } func (r *NotificationRepository) DeleteFCMToken(ctx context.Context, userID string, token string) error { - commandTag, err := r.pool.Exec(ctx, ` + _, err := r.pool.Exec(ctx, ` DELETE FROM public.user_fcm_tokens WHERE user_id = $1::uuid AND token = $2 `, userID, token) - if err != nil { - return err + return err +} + +func (r *NotificationRepository) DeleteAllFCMTokensForUser(ctx context.Context, userID string) error { + _, err := r.pool.Exec(ctx, ` + DELETE FROM public.user_fcm_tokens WHERE user_id = $1::uuid + `, userID) + return err +} + +// ============================================================================ +// Notification CRUD +// ============================================================================ + +func (r *NotificationRepository) CreateNotification(ctx context.Context, notif *models.Notification) error { + query := ` + INSERT INTO public.notifications (user_id, type, actor_id, post_id, comment_id, is_read, metadata, group_key, priority) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id, created_at + ` + + priority := notif.Priority + if priority == "" { + priority = models.PriorityNormal } - if commandTag.RowsAffected() == 0 { - return nil - } - return nil + + err := r.pool.QueryRow(ctx, query, + notif.UserID, notif.Type, notif.ActorID, notif.PostID, notif.CommentID, notif.IsRead, notif.Metadata, notif.GroupKey, priority, + ).Scan(¬if.ID, ¬if.CreatedAt) + + return err } func (r *NotificationRepository) GetNotifications(ctx context.Context, userID string, limit, offset int) ([]models.Notification, error) { query := ` SELECT n.id, n.user_id, n.type, n.actor_id, n.post_id, n.comment_id, n.is_read, n.created_at, n.metadata, + COALESCE(n.group_key, '') as group_key, + COALESCE(n.priority, 'normal') as priority, pr.handle, pr.display_name, COALESCE(pr.avatar_url, ''), - po.image_url + po.image_url, + LEFT(po.body, 100) as post_body FROM public.notifications n JOIN public.profiles pr ON n.actor_id = pr.id LEFT JOIN public.posts po ON n.post_id = po.id @@ -95,34 +129,417 @@ func (r *NotificationRepository) GetNotifications(ctx context.Context, userID st notifications := []models.Notification{} for rows.Next() { var n models.Notification + var groupKey string var postImageURL *string + var postBody *string err := rows.Scan( &n.ID, &n.UserID, &n.Type, &n.ActorID, &n.PostID, &n.CommentID, &n.IsRead, &n.CreatedAt, &n.Metadata, + &groupKey, &n.Priority, &n.ActorHandle, &n.ActorDisplayName, &n.ActorAvatarURL, - &postImageURL, + &postImageURL, &postBody, ) if err != nil { return nil, err } + if groupKey != "" { + n.GroupKey = &groupKey + } n.PostImageURL = postImageURL + n.PostBody = postBody notifications = append(notifications, n) } return notifications, nil } -func (r *NotificationRepository) CreateNotification(ctx context.Context, notif *models.Notification) error { +// GetGroupedNotifications returns notifications with grouping (e.g., "5 people liked your post") +func (r *NotificationRepository) GetGroupedNotifications(ctx context.Context, userID string, limit, offset int) ([]models.Notification, error) { query := ` - INSERT INTO public.notifications (user_id, type, actor_id, post_id, comment_id, is_read, metadata) - VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING id, created_at + WITH ranked AS ( + SELECT + n.*, + pr.handle as actor_handle, + pr.display_name as actor_display_name, + COALESCE(pr.avatar_url, '') as actor_avatar_url, + po.image_url as post_image_url, + LEFT(po.body, 100) as post_body, + COUNT(*) OVER (PARTITION BY n.group_key) as group_count, + ROW_NUMBER() OVER (PARTITION BY n.group_key ORDER BY n.created_at DESC) as rn + FROM public.notifications n + JOIN public.profiles pr ON n.actor_id = pr.id + LEFT JOIN public.posts po ON n.post_id = po.id + WHERE n.user_id = $1::uuid + ) + SELECT + id, user_id, type, actor_id, post_id, comment_id, is_read, created_at, metadata, + COALESCE(group_key, '') as group_key, + COALESCE(priority, 'normal') as priority, + actor_handle, actor_display_name, actor_avatar_url, + post_image_url, post_body, + group_count + FROM ranked + WHERE rn = 1 OR group_key IS NULL + ORDER BY created_at DESC + LIMIT $2 OFFSET $3 ` - err := r.pool.QueryRow(ctx, query, - notif.UserID, notif.Type, notif.ActorID, notif.PostID, notif.CommentID, notif.IsRead, notif.Metadata, - ).Scan(¬if.ID, ¬if.CreatedAt) + rows, err := r.pool.Query(ctx, query, userID, limit, offset) if err != nil { - return err + return nil, err + } + defer rows.Close() + + notifications := []models.Notification{} + for rows.Next() { + var n models.Notification + var groupKey string + err := rows.Scan( + &n.ID, &n.UserID, &n.Type, &n.ActorID, &n.PostID, &n.CommentID, &n.IsRead, &n.CreatedAt, &n.Metadata, + &groupKey, &n.Priority, + &n.ActorHandle, &n.ActorDisplayName, &n.ActorAvatarURL, + &n.PostImageURL, &n.PostBody, + &n.GroupCount, + ) + if err != nil { + return nil, err + } + if groupKey != "" { + n.GroupKey = &groupKey + } + notifications = append(notifications, n) + } + return notifications, nil +} + +func (r *NotificationRepository) MarkAsRead(ctx context.Context, notificationID, userID string) error { + _, err := r.pool.Exec(ctx, ` + UPDATE public.notifications SET is_read = TRUE + WHERE id = $1::uuid AND user_id = $2::uuid + `, notificationID, userID) + return err +} + +func (r *NotificationRepository) MarkAllAsRead(ctx context.Context, userID string) error { + _, err := r.pool.Exec(ctx, ` + UPDATE public.notifications SET is_read = TRUE + WHERE user_id = $1::uuid AND is_read = FALSE + `, userID) + return err +} + +func (r *NotificationRepository) DeleteNotification(ctx context.Context, notificationID, userID string) error { + _, err := r.pool.Exec(ctx, ` + DELETE FROM public.notifications + WHERE id = $1::uuid AND user_id = $2::uuid + `, notificationID, userID) + return err +} + +func (r *NotificationRepository) GetUnreadCount(ctx context.Context, userID string) (int, error) { + var count int + err := r.pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM public.notifications + WHERE user_id = $1::uuid AND is_read = FALSE + `, userID).Scan(&count) + return count, err +} + +func (r *NotificationRepository) GetUnreadBadge(ctx context.Context, userID string) (*models.UnreadBadge, error) { + badge := &models.UnreadBadge{} + + // Get notification count + err := r.pool.QueryRow(ctx, ` + SELECT COALESCE(unread_notification_count, 0) FROM profiles WHERE id = $1::uuid + `, userID).Scan(&badge.NotificationCount) + if err != nil { + return nil, err } - return nil + // Get unread message count + err = r.pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM encrypted_messages + WHERE receiver_id = $1::uuid AND is_read = FALSE + `, userID).Scan(&badge.MessageCount) + if err != nil { + // Table might not exist or column missing, ignore + badge.MessageCount = 0 + } + + badge.TotalCount = badge.NotificationCount + badge.MessageCount + return badge, nil +} + +// ============================================================================ +// Notification Preferences +// ============================================================================ + +func (r *NotificationRepository) GetNotificationPreferences(ctx context.Context, userID string) (*models.NotificationPreferences, error) { + prefs := &models.NotificationPreferences{} + + err := r.pool.QueryRow(ctx, ` + SELECT + user_id, push_enabled, push_likes, push_comments, push_replies, push_mentions, + push_follows, push_follow_requests, push_messages, push_saves, push_beacons, + email_enabled, email_digest_frequency, quiet_hours_enabled, + quiet_hours_start::text, quiet_hours_end::text, show_badge_count, + created_at, updated_at + FROM notification_preferences + WHERE user_id = $1::uuid + `, userID).Scan( + &prefs.UserID, &prefs.PushEnabled, &prefs.PushLikes, &prefs.PushComments, &prefs.PushReplies, &prefs.PushMentions, + &prefs.PushFollows, &prefs.PushFollowRequests, &prefs.PushMessages, &prefs.PushSaves, &prefs.PushBeacons, + &prefs.EmailEnabled, &prefs.EmailDigestFrequency, &prefs.QuietHoursEnabled, + &prefs.QuietHoursStart, &prefs.QuietHoursEnd, &prefs.ShowBadgeCount, + &prefs.CreatedAt, &prefs.UpdatedAt, + ) + + if err != nil { + // Return defaults if not found + return r.createDefaultPreferences(ctx, userID) + } + + return prefs, nil +} + +func (r *NotificationRepository) createDefaultPreferences(ctx context.Context, userID string) (*models.NotificationPreferences, error) { + userUUID, err := uuid.Parse(userID) + if err != nil { + return nil, err + } + + prefs := &models.NotificationPreferences{ + UserID: userUUID, + PushEnabled: true, + PushLikes: true, + PushComments: true, + PushReplies: true, + PushMentions: true, + PushFollows: true, + PushFollowRequests: true, + PushMessages: true, + PushSaves: true, + PushBeacons: true, + EmailEnabled: false, + EmailDigestFrequency: "never", + QuietHoursEnabled: false, + ShowBadgeCount: true, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + _, err = r.pool.Exec(ctx, ` + INSERT INTO notification_preferences (user_id, push_enabled, push_likes, push_comments, push_replies, push_mentions, + push_follows, push_follow_requests, push_messages, push_saves, push_beacons, + email_enabled, email_digest_frequency, quiet_hours_enabled, show_badge_count) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + ON CONFLICT (user_id) DO NOTHING + `, + prefs.UserID, prefs.PushEnabled, prefs.PushLikes, prefs.PushComments, prefs.PushReplies, prefs.PushMentions, + prefs.PushFollows, prefs.PushFollowRequests, prefs.PushMessages, prefs.PushSaves, prefs.PushBeacons, + prefs.EmailEnabled, prefs.EmailDigestFrequency, prefs.QuietHoursEnabled, prefs.ShowBadgeCount, + ) + + return prefs, err +} + +func (r *NotificationRepository) UpdateNotificationPreferences(ctx context.Context, prefs *models.NotificationPreferences) error { + _, err := r.pool.Exec(ctx, ` + INSERT INTO notification_preferences ( + user_id, push_enabled, push_likes, push_comments, push_replies, push_mentions, + push_follows, push_follow_requests, push_messages, push_saves, push_beacons, + email_enabled, email_digest_frequency, quiet_hours_enabled, quiet_hours_start, quiet_hours_end, + show_badge_count, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15::time, $16::time, $17, NOW()) + ON CONFLICT (user_id) DO UPDATE SET + push_enabled = EXCLUDED.push_enabled, + push_likes = EXCLUDED.push_likes, + push_comments = EXCLUDED.push_comments, + push_replies = EXCLUDED.push_replies, + push_mentions = EXCLUDED.push_mentions, + push_follows = EXCLUDED.push_follows, + push_follow_requests = EXCLUDED.push_follow_requests, + push_messages = EXCLUDED.push_messages, + push_saves = EXCLUDED.push_saves, + push_beacons = EXCLUDED.push_beacons, + email_enabled = EXCLUDED.email_enabled, + email_digest_frequency = EXCLUDED.email_digest_frequency, + quiet_hours_enabled = EXCLUDED.quiet_hours_enabled, + quiet_hours_start = EXCLUDED.quiet_hours_start, + quiet_hours_end = EXCLUDED.quiet_hours_end, + show_badge_count = EXCLUDED.show_badge_count, + updated_at = NOW() + `, + prefs.UserID, prefs.PushEnabled, prefs.PushLikes, prefs.PushComments, prefs.PushReplies, prefs.PushMentions, + prefs.PushFollows, prefs.PushFollowRequests, prefs.PushMessages, prefs.PushSaves, prefs.PushBeacons, + prefs.EmailEnabled, prefs.EmailDigestFrequency, prefs.QuietHoursEnabled, prefs.QuietHoursStart, prefs.QuietHoursEnd, + prefs.ShowBadgeCount, + ) + return err +} + +// ShouldSendPush checks user preferences and quiet hours to determine if push should be sent +func (r *NotificationRepository) ShouldSendPush(ctx context.Context, userID, notificationType string) (bool, error) { + prefs, err := r.GetNotificationPreferences(ctx, userID) + if err != nil { + log.Warn().Err(err).Str("user_id", userID).Msg("Failed to get notification preferences, defaulting to send") + return true, nil + } + + if !prefs.PushEnabled { + return false, nil + } + + // Check quiet hours + if prefs.QuietHoursEnabled && prefs.QuietHoursStart != nil && prefs.QuietHoursEnd != nil { + if r.isInQuietHours(*prefs.QuietHoursStart, *prefs.QuietHoursEnd) { + return false, nil + } + } + + // Check specific notification type + switch notificationType { + case models.NotificationTypeLike: + return prefs.PushLikes, nil + case models.NotificationTypeComment: + return prefs.PushComments, nil + case models.NotificationTypeReply: + return prefs.PushReplies, nil + case models.NotificationTypeMention: + return prefs.PushMentions, nil + case models.NotificationTypeFollow: + return prefs.PushFollows, nil + case models.NotificationTypeFollowRequest: + return prefs.PushFollowRequests, nil + case models.NotificationTypeMessage: + return prefs.PushMessages, nil + case models.NotificationTypeSave: + return prefs.PushSaves, nil + case models.NotificationTypeBeaconVouch, models.NotificationTypeBeaconReport: + return prefs.PushBeacons, nil + default: + return true, nil + } +} + +func (r *NotificationRepository) isInQuietHours(start, end string) bool { + now := time.Now().UTC() + currentTime := now.Format("15:04:05") + + // Simple string comparison for time ranges + // Handle cases where quiet hours span midnight + if start > end { + // Spans midnight: 22:00 -> 08:00 + return currentTime >= start || currentTime <= end + } + // Same day: 23:00 -> 23:59 + return currentTime >= start && currentTime <= end +} + +// ============================================================================ +// Mention Extraction +// ============================================================================ + +// ExtractMentions finds @username patterns in text and returns user IDs +func (r *NotificationRepository) ExtractMentions(ctx context.Context, text string) ([]uuid.UUID, error) { + // Extract @mentions using regex + mentions := extractMentionHandles(text) + if len(mentions) == 0 { + return nil, nil + } + + // Look up user IDs for handles + query := `SELECT id FROM profiles WHERE handle = ANY($1)` + rows, err := r.pool.Query(ctx, query, mentions) + if err != nil { + return nil, err + } + defer rows.Close() + + var userIDs []uuid.UUID + for rows.Next() { + var id uuid.UUID + if err := rows.Scan(&id); err != nil { + continue + } + userIDs = append(userIDs, id) + } + + return userIDs, nil +} + +func extractMentionHandles(text string) []string { + var mentions []string + inMention := false + current := "" + + for i, r := range text { + if r == '@' { + inMention = true + current = "" + continue + } + + if inMention { + // Valid handle characters: alphanumeric and underscore + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' { + current += string(r) + } else { + if len(current) > 0 { + mentions = append(mentions, current) + } + inMention = false + current = "" + } + } + + // Check end of string + if i == len(text)-1 && inMention && len(current) > 0 { + mentions = append(mentions, current) + } + } + + return mentions +} + +// ============================================================================ +// Notification Cleanup +// ============================================================================ + +// DeleteOldNotifications removes notifications older than the specified days +func (r *NotificationRepository) DeleteOldNotifications(ctx context.Context, daysOld int) (int64, error) { + result, err := r.pool.Exec(ctx, ` + DELETE FROM notifications + WHERE created_at < NOW() - INTERVAL '1 day' * $1 + `, daysOld) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +// ArchiveOldNotifications marks old notifications as archived instead of deleting +func (r *NotificationRepository) ArchiveOldNotifications(ctx context.Context, daysOld int) (int64, error) { + result, err := r.pool.Exec(ctx, ` + UPDATE notifications + SET archived_at = NOW() + WHERE created_at < NOW() - INTERVAL '1 day' * $1 AND archived_at IS NULL + `, daysOld) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +// ============================================================================ +// Helper for building metadata JSON +// ============================================================================ + +func BuildNotificationMetadata(data map[string]interface{}) json.RawMessage { + if data == nil { + return json.RawMessage("{}") + } + bytes, err := json.Marshal(data) + if err != nil { + return json.RawMessage("{}") + } + return bytes } diff --git a/go-backend/internal/repository/post_repository.go b/go-backend/internal/repository/post_repository.go index cf80cff..e6410db 100644 --- a/go-backend/internal/repository/post_repository.go +++ b/go-backend/internal/repository/post_repository.go @@ -153,6 +153,7 @@ func (r *PostRepository) GetFeed(ctx context.Context, userID string, categorySlu WHERE f.follower_id = CASE WHEN $4::text != '' THEN $4::text::uuid ELSE NULL END AND f.following_id = p.author_id AND f.status = 'accepted' ) ) + AND NOT public.has_block_between(p.author_id, CASE WHEN $4::text != '' THEN $4::text::uuid ELSE NULL END) AND ($3 = FALSE OR (COALESCE(p.video_url, '') <> '' OR (COALESCE(p.image_url, '') ILIKE '%.mp4'))) AND ($5 = '' OR c.slug = $5) ORDER BY p.created_at DESC @@ -306,6 +307,7 @@ func (r *PostRepository) GetPostByID(ctx context.Context, postID string, userID WHERE f.follower_id = CASE WHEN $2 != '' THEN $2::uuid ELSE NULL END AND f.following_id = p.author_id AND f.status = 'accepted' ) ) + AND NOT public.has_block_between(p.author_id, CASE WHEN $2 != '' THEN $2::uuid ELSE NULL END) ` var p models.Post err := r.pool.QueryRow(ctx, query, postID, userID).Scan( @@ -1263,3 +1265,59 @@ func (r *PostRepository) LoadReactionsForPost(ctx context.Context, postID string return counts, myReactions, reactionUsers, nil } + +func (r *PostRepository) GetPopularPublicPosts(ctx context.Context, viewerID string, limit int) ([]models.Post, error) { + query := ` + SELECT + p.id, p.author_id, p.category_id, p.body, + COALESCE(p.image_url, ''), + CASE + WHEN COALESCE(p.video_url, '') <> '' THEN p.video_url + WHEN COALESCE(p.image_url, '') ILIKE '%.mp4' THEN p.image_url + ELSE '' + END AS resolved_video_url, + COALESCE(NULLIF(p.thumbnail_url, ''), p.image_url, '') AS resolved_thumbnail_url, + p.duration_ms, + COALESCE(p.tags, ARRAY[]::text[]), + p.created_at, + pr.handle as author_handle, pr.display_name as author_display_name, COALESCE(pr.avatar_url, '') as author_avatar_url, + COALESCE(m.like_count, 0) as like_count, COALESCE(m.comment_count, 0) as comment_count, + CASE WHEN ($2::text) != '' THEN EXISTS(SELECT 1 FROM public.post_likes WHERE post_id = p.id AND user_id = $2::text::uuid) ELSE FALSE END as is_liked, + p.allow_chain, p.visibility + FROM public.posts p + JOIN public.profiles pr ON p.author_id = pr.id + LEFT JOIN public.post_metrics m ON p.id = m.post_id + WHERE p.deleted_at IS NULL AND p.status = 'active' + AND pr.is_private = FALSE + AND p.visibility = 'public' + ORDER BY (COALESCE(m.like_count, 0) * 2 + COALESCE(m.comment_count, 0) * 5) DESC, p.created_at DESC + LIMIT $1 + ` + rows, err := r.pool.Query(ctx, query, limit, viewerID) + if err != nil { + return nil, err + } + defer rows.Close() + + posts := []models.Post{} + for rows.Next() { + var p models.Post + err := rows.Scan( + &p.ID, &p.AuthorID, &p.CategoryID, &p.Body, &p.ImageURL, &p.VideoURL, &p.ThumbnailURL, &p.DurationMS, &p.Tags, &p.CreatedAt, + &p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL, + &p.LikeCount, &p.CommentCount, &p.IsLiked, + &p.AllowChain, &p.Visibility, + ) + if err != nil { + return nil, err + } + p.Author = &models.AuthorProfile{ + ID: p.AuthorID, + Handle: p.AuthorHandle, + DisplayName: p.AuthorDisplayName, + AvatarURL: p.AuthorAvatarURL, + } + posts = append(posts, p) + } + return posts, nil +} diff --git a/go-backend/internal/repository/tag_repository.go b/go-backend/internal/repository/tag_repository.go new file mode 100644 index 0000000..bd8c10b --- /dev/null +++ b/go-backend/internal/repository/tag_repository.go @@ -0,0 +1,569 @@ +package repository + +import ( + "context" + "regexp" + "strings" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/patbritton/sojorn-backend/internal/models" + "github.com/rs/zerolog/log" +) + +type TagRepository struct { + pool *pgxpool.Pool +} + +func NewTagRepository(pool *pgxpool.Pool) *TagRepository { + return &TagRepository{pool: pool} +} + +// ============================================================================ +// Hashtag CRUD +// ============================================================================ + +// GetOrCreateHashtag finds or creates a hashtag by name +func (r *TagRepository) GetOrCreateHashtag(ctx context.Context, name string) (*models.Hashtag, error) { + normalized := strings.ToLower(strings.TrimPrefix(name, "#")) + if normalized == "" { + return nil, nil + } + + var hashtag models.Hashtag + err := r.pool.QueryRow(ctx, ` + INSERT INTO hashtags (name, display_name) + VALUES ($1, $2) + ON CONFLICT (name) DO UPDATE SET updated_at = NOW() + RETURNING id, name, display_name, use_count, trending_score, is_trending, is_featured, category, created_at + `, normalized, name).Scan( + &hashtag.ID, &hashtag.Name, &hashtag.DisplayName, &hashtag.UseCount, + &hashtag.TrendingScore, &hashtag.IsTrending, &hashtag.IsFeatured, &hashtag.Category, &hashtag.CreatedAt, + ) + if err != nil { + return nil, err + } + + return &hashtag, nil +} + +// GetHashtagByName retrieves a hashtag by its normalized name +func (r *TagRepository) GetHashtagByName(ctx context.Context, name string) (*models.Hashtag, error) { + normalized := strings.ToLower(strings.TrimPrefix(name, "#")) + + var hashtag models.Hashtag + err := r.pool.QueryRow(ctx, ` + SELECT id, name, display_name, use_count, trending_score, is_trending, is_featured, category, created_at + FROM hashtags WHERE name = $1 + `, normalized).Scan( + &hashtag.ID, &hashtag.Name, &hashtag.DisplayName, &hashtag.UseCount, + &hashtag.TrendingScore, &hashtag.IsTrending, &hashtag.IsFeatured, &hashtag.Category, &hashtag.CreatedAt, + ) + if err != nil { + if err == pgx.ErrNoRows { + return nil, nil + } + return nil, err + } + + return &hashtag, nil +} + +// SearchHashtags searches for hashtags matching a query +func (r *TagRepository) SearchHashtags(ctx context.Context, query string, limit int) ([]models.Hashtag, error) { + normalized := strings.ToLower(strings.TrimPrefix(query, "#")) + + rows, err := r.pool.Query(ctx, ` + SELECT id, name, display_name, use_count, trending_score, is_trending, is_featured, category, created_at + FROM hashtags + WHERE name ILIKE $1 || '%' + ORDER BY use_count DESC, name ASC + LIMIT $2 + `, normalized, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var hashtags []models.Hashtag + for rows.Next() { + var h models.Hashtag + err := rows.Scan( + &h.ID, &h.Name, &h.DisplayName, &h.UseCount, + &h.TrendingScore, &h.IsTrending, &h.IsFeatured, &h.Category, &h.CreatedAt, + ) + if err != nil { + return nil, err + } + hashtags = append(hashtags, h) + } + + return hashtags, nil +} + +// ============================================================================ +// Post-Hashtag Linking +// ============================================================================ + +// LinkHashtagsToPost extracts hashtags from text and links them to a post +func (r *TagRepository) LinkHashtagsToPost(ctx context.Context, postID uuid.UUID, text string) error { + hashtags := extractHashtags(text) + if len(hashtags) == 0 { + return nil + } + + tx, err := r.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + for _, tag := range hashtags { + // Get or create the hashtag + var hashtagID uuid.UUID + err := tx.QueryRow(ctx, ` + INSERT INTO hashtags (name, display_name) + VALUES ($1, $2) + ON CONFLICT (name) DO UPDATE SET updated_at = NOW() + RETURNING id + `, strings.ToLower(tag), tag).Scan(&hashtagID) + if err != nil { + log.Warn().Err(err).Str("tag", tag).Msg("Failed to create hashtag") + continue + } + + // Link to post + _, err = tx.Exec(ctx, ` + INSERT INTO post_hashtags (post_id, hashtag_id) + VALUES ($1, $2) + ON CONFLICT DO NOTHING + `, postID, hashtagID) + if err != nil { + log.Warn().Err(err).Msg("Failed to link hashtag to post") + } + } + + return tx.Commit(ctx) +} + +// UnlinkHashtagsFromPost removes all hashtag links for a post +func (r *TagRepository) UnlinkHashtagsFromPost(ctx context.Context, postID uuid.UUID) error { + _, err := r.pool.Exec(ctx, `DELETE FROM post_hashtags WHERE post_id = $1`, postID) + return err +} + +// GetHashtagsForPost returns all hashtags linked to a post +func (r *TagRepository) GetHashtagsForPost(ctx context.Context, postID uuid.UUID) ([]models.Hashtag, error) { + rows, err := r.pool.Query(ctx, ` + SELECT h.id, h.name, h.display_name, h.use_count, h.trending_score, h.is_trending, h.is_featured, h.category, h.created_at + FROM hashtags h + JOIN post_hashtags ph ON h.id = ph.hashtag_id + WHERE ph.post_id = $1 + ORDER BY h.name + `, postID) + if err != nil { + return nil, err + } + defer rows.Close() + + var hashtags []models.Hashtag + for rows.Next() { + var h models.Hashtag + err := rows.Scan( + &h.ID, &h.Name, &h.DisplayName, &h.UseCount, + &h.TrendingScore, &h.IsTrending, &h.IsFeatured, &h.Category, &h.CreatedAt, + ) + if err != nil { + return nil, err + } + hashtags = append(hashtags, h) + } + + return hashtags, nil +} + +// GetPostsByHashtag returns posts that contain a specific hashtag +func (r *TagRepository) GetPostsByHashtag(ctx context.Context, hashtagName, viewerID string, limit, offset int) ([]models.Post, error) { + normalized := strings.ToLower(strings.TrimPrefix(hashtagName, "#")) + + rows, err := r.pool.Query(ctx, ` + SELECT + p.id, p.author_id, p.body, p.image_url, p.video_url, p.status, p.created_at, + p.tone_label, p.cis_score, p.body_format, p.tags, p.visibility, p.is_beacon, + pr.id, pr.handle, pr.display_name, pr.avatar_url, + (SELECT COUNT(*) FROM post_likes WHERE post_id = p.id) as like_count, + EXISTS(SELECT 1 FROM post_likes WHERE post_id = p.id AND user_id = $3::uuid) as user_has_liked + FROM posts p + JOIN post_hashtags ph ON p.id = ph.post_id + JOIN hashtags h ON ph.hashtag_id = h.id + JOIN profiles pr ON p.author_id = pr.id + WHERE h.name = $1 + AND p.deleted_at IS NULL + AND p.status = 'active' + ORDER BY p.created_at DESC + LIMIT $4 OFFSET $5 + `, normalized, normalized, viewerID, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var posts []models.Post + for rows.Next() { + var p models.Post + var authorID uuid.UUID + var authorHandle, authorDisplayName, authorAvatarURL *string + var likeCount int + var isLiked bool + + err := rows.Scan( + &p.ID, &p.AuthorID, &p.Body, &p.ImageURL, &p.VideoURL, &p.Status, &p.CreatedAt, + &p.ToneLabel, &p.CISScore, &p.BodyFormat, &p.Tags, &p.Visibility, &p.IsBeacon, + &authorID, &authorHandle, &authorDisplayName, &authorAvatarURL, + &likeCount, &isLiked, + ) + if err != nil { + return nil, err + } + + p.LikeCount = likeCount + p.IsLiked = isLiked + + // Build author profile + handle := "" + displayName := "" + avatarURL := "" + if authorHandle != nil { + handle = *authorHandle + } + if authorDisplayName != nil { + displayName = *authorDisplayName + } + if authorAvatarURL != nil { + avatarURL = *authorAvatarURL + } + p.Author = &models.AuthorProfile{ + ID: authorID, + Handle: handle, + DisplayName: displayName, + AvatarURL: avatarURL, + } + + posts = append(posts, p) + } + + return posts, nil +} + +// ============================================================================ +// Trending & Discover +// ============================================================================ + +// GetTrendingHashtags returns top trending hashtags +func (r *TagRepository) GetTrendingHashtags(ctx context.Context, limit int) ([]models.Hashtag, error) { + rows, err := r.pool.Query(ctx, ` + SELECT h.id, h.name, h.display_name, h.use_count, h.trending_score, h.is_trending, h.is_featured, h.category, h.created_at, + (SELECT COUNT(*) FROM post_hashtags ph WHERE ph.hashtag_id = h.id AND ph.created_at > NOW() - INTERVAL '24 hours') as recent_count + FROM hashtags h + WHERE h.trending_score > 0 + ORDER BY h.trending_score DESC, h.use_count DESC + LIMIT $1 + `, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var hashtags []models.Hashtag + for rows.Next() { + var h models.Hashtag + var recentCount int + err := rows.Scan( + &h.ID, &h.Name, &h.DisplayName, &h.UseCount, + &h.TrendingScore, &h.IsTrending, &h.IsFeatured, &h.Category, &h.CreatedAt, &recentCount, + ) + if err != nil { + return nil, err + } + h.RecentCount = recentCount + hashtags = append(hashtags, h) + } + + return hashtags, nil +} + +// GetFeaturedHashtags returns curated/featured hashtags +func (r *TagRepository) GetFeaturedHashtags(ctx context.Context, limit int) ([]models.Hashtag, error) { + rows, err := r.pool.Query(ctx, ` + SELECT id, name, display_name, use_count, trending_score, is_trending, is_featured, category, created_at + FROM hashtags + WHERE is_featured = true + ORDER BY use_count DESC + LIMIT $1 + `, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var hashtags []models.Hashtag + for rows.Next() { + var h models.Hashtag + err := rows.Scan( + &h.ID, &h.Name, &h.DisplayName, &h.UseCount, + &h.TrendingScore, &h.IsTrending, &h.IsFeatured, &h.Category, &h.CreatedAt, + ) + if err != nil { + return nil, err + } + hashtags = append(hashtags, h) + } + + return hashtags, nil +} + +// ============================================================================ +// Hashtag Follows +// ============================================================================ + +// FollowHashtag adds a user to a hashtag's followers +func (r *TagRepository) FollowHashtag(ctx context.Context, userID, hashtagID uuid.UUID) error { + _, err := r.pool.Exec(ctx, ` + INSERT INTO hashtag_follows (user_id, hashtag_id) + VALUES ($1, $2) + ON CONFLICT DO NOTHING + `, userID, hashtagID) + return err +} + +// UnfollowHashtag removes a user from a hashtag's followers +func (r *TagRepository) UnfollowHashtag(ctx context.Context, userID, hashtagID uuid.UUID) error { + _, err := r.pool.Exec(ctx, `DELETE FROM hashtag_follows WHERE user_id = $1 AND hashtag_id = $2`, userID, hashtagID) + return err +} + +// GetFollowedHashtags returns hashtags followed by a user +func (r *TagRepository) GetFollowedHashtags(ctx context.Context, userID uuid.UUID) ([]models.Hashtag, error) { + rows, err := r.pool.Query(ctx, ` + SELECT h.id, h.name, h.display_name, h.use_count, h.trending_score, h.is_trending, h.is_featured, h.category, h.created_at + FROM hashtags h + JOIN hashtag_follows hf ON h.id = hf.hashtag_id + WHERE hf.user_id = $1 + ORDER BY h.name + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var hashtags []models.Hashtag + for rows.Next() { + var h models.Hashtag + err := rows.Scan( + &h.ID, &h.Name, &h.DisplayName, &h.UseCount, + &h.TrendingScore, &h.IsTrending, &h.IsFeatured, &h.Category, &h.CreatedAt, + ) + if err != nil { + return nil, err + } + hashtags = append(hashtags, h) + } + + return hashtags, nil +} + +// IsFollowingHashtag checks if a user is following a hashtag +func (r *TagRepository) IsFollowingHashtag(ctx context.Context, userID, hashtagID uuid.UUID) (bool, error) { + var exists bool + err := r.pool.QueryRow(ctx, ` + SELECT EXISTS(SELECT 1 FROM hashtag_follows WHERE user_id = $1 AND hashtag_id = $2) + `, userID, hashtagID).Scan(&exists) + return exists, err +} + +// ============================================================================ +// Mentions +// ============================================================================ + +// LinkMentionsToPost extracts @mentions from text and links them to a post +func (r *TagRepository) LinkMentionsToPost(ctx context.Context, postID uuid.UUID, text string) ([]uuid.UUID, error) { + mentions := extractMentions(text) + if len(mentions) == 0 { + return nil, nil + } + + var mentionedUserIDs []uuid.UUID + + for _, handle := range mentions { + var userID uuid.UUID + err := r.pool.QueryRow(ctx, `SELECT id FROM profiles WHERE handle = $1`, handle).Scan(&userID) + if err != nil { + continue // User not found + } + + _, err = r.pool.Exec(ctx, ` + INSERT INTO post_mentions (post_id, mentioned_user_id) + VALUES ($1, $2) + ON CONFLICT DO NOTHING + `, postID, userID) + if err == nil { + mentionedUserIDs = append(mentionedUserIDs, userID) + } + } + + return mentionedUserIDs, nil +} + +// GetMentionsForPost returns all mentioned users in a post +func (r *TagRepository) GetMentionsForPost(ctx context.Context, postID uuid.UUID) ([]models.Profile, error) { + rows, err := r.pool.Query(ctx, ` + SELECT p.id, p.handle, p.display_name, p.avatar_url, p.is_verified + FROM profiles p + JOIN post_mentions pm ON p.id = pm.mentioned_user_id + WHERE pm.post_id = $1 + `, postID) + if err != nil { + return nil, err + } + defer rows.Close() + + var profiles []models.Profile + for rows.Next() { + var p models.Profile + if err := rows.Scan(&p.ID, &p.Handle, &p.DisplayName, &p.AvatarURL, &p.IsVerified); err != nil { + return nil, err + } + profiles = append(profiles, p) + } + + return profiles, nil +} + +// ============================================================================ +// Suggested Users (Discover) +// ============================================================================ + +// GetSuggestedUsers returns suggested users for the discover page +func (r *TagRepository) GetSuggestedUsers(ctx context.Context, userID string, limit int) ([]models.SuggestedUser, error) { + rows, err := r.pool.Query(ctx, ` + SELECT + p.id, p.handle, p.display_name, p.avatar_url, p.bio, p.is_verified, p.is_official, + su.reason, su.category, su.score, + (SELECT COUNT(*) FROM follows WHERE following_id = p.id AND status = 'accepted') as follower_count + FROM suggested_users su + JOIN profiles p ON su.user_id = p.id + WHERE su.is_active = true + AND p.id != $1::uuid + AND NOT EXISTS (SELECT 1 FROM follows WHERE follower_id = $1::uuid AND following_id = p.id) + ORDER BY su.score DESC, p.is_verified DESC + LIMIT $2 + `, userID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var users []models.SuggestedUser + for rows.Next() { + var u models.SuggestedUser + err := rows.Scan( + &u.ID, &u.Handle, &u.DisplayName, &u.AvatarURL, &u.Bio, &u.IsVerified, &u.IsOfficial, + &u.Reason, &u.Category, &u.Score, &u.FollowerCount, + ) + if err != nil { + return nil, err + } + users = append(users, u) + } + + return users, nil +} + +// GetPopularCreators returns popular users for the discover page +func (r *TagRepository) GetPopularCreators(ctx context.Context, userID string, limit int) ([]models.Profile, error) { + rows, err := r.pool.Query(ctx, ` + SELECT + p.id, p.handle, p.display_name, p.avatar_url, p.bio, p.is_verified, p.is_official, + (SELECT COUNT(*) FROM follows WHERE following_id = p.id AND status = 'accepted') as follower_count + FROM profiles p + WHERE p.id != $1::uuid + AND p.is_official = false + AND NOT EXISTS (SELECT 1 FROM follows WHERE follower_id = $1::uuid AND following_id = p.id) + ORDER BY (SELECT COUNT(*) FROM follows WHERE following_id = p.id AND status = 'accepted') DESC + LIMIT $2 + `, userID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var profiles []models.Profile + for rows.Next() { + var p models.Profile + var followerCount int + err := rows.Scan( + &p.ID, &p.Handle, &p.DisplayName, &p.AvatarURL, &p.Bio, &p.IsVerified, &p.IsOfficial, &followerCount, + ) + if err != nil { + return nil, err + } + p.FollowerCount = &followerCount + profiles = append(profiles, p) + } + + return profiles, nil +} + +// ============================================================================ +// Trending Calculation (for scheduled jobs) +// ============================================================================ + +// RefreshTrendingScores recalculates trending scores for all hashtags +func (r *TagRepository) RefreshTrendingScores(ctx context.Context) error { + _, err := r.pool.Exec(ctx, `SELECT calculate_trending_scores()`) + return err +} + +// ============================================================================ +// Helpers +// ============================================================================ + +var hashtagRegex = regexp.MustCompile(`#(\w+)`) +var mentionRegex = regexp.MustCompile(`@(\w+)`) + +func extractHashtags(text string) []string { + matches := hashtagRegex.FindAllStringSubmatch(text, -1) + seen := make(map[string]bool) + var hashtags []string + + for _, match := range matches { + if len(match) > 1 { + tag := match[1] + if !seen[strings.ToLower(tag)] { + seen[strings.ToLower(tag)] = true + hashtags = append(hashtags, tag) + } + } + } + + return hashtags +} + +func extractMentions(text string) []string { + matches := mentionRegex.FindAllStringSubmatch(text, -1) + seen := make(map[string]bool) + var mentions []string + + for _, match := range matches { + if len(match) > 1 { + handle := strings.ToLower(match[1]) + if !seen[handle] { + seen[handle] = true + mentions = append(mentions, handle) + } + } + } + + return mentions +} diff --git a/go-backend/internal/repository/user_repository.go b/go-backend/internal/repository/user_repository.go index e9c633f..c6b2a40 100644 --- a/go-backend/internal/repository/user_repository.go +++ b/go-backend/internal/repository/user_repository.go @@ -314,6 +314,79 @@ func (r *UserRepository) UnfollowUser(ctx context.Context, followerID, following return nil } +func (r *UserRepository) BlockUser(ctx context.Context, blockerID, blockedID, actorIP string) error { + tx, err := r.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + // Step 1: Insert Block + query := `INSERT INTO public.blocks (blocker_id, blocked_id) VALUES ($1::uuid, $2::uuid) ON CONFLICT DO NOTHING` + _, err = tx.Exec(ctx, query, blockerID, blockedID) + if err != nil { + return err + } + + // Step 2: Log Abuse + var handle string + _ = tx.QueryRow(ctx, `SELECT handle FROM public.profiles WHERE id = $1::uuid`, blockedID).Scan(&handle) + + abuseQuery := ` + INSERT INTO public.abuse_logs (actor_id, blocked_id, blocked_handle, actor_ip) + VALUES ($1::uuid, $2::uuid, $3, $4) + ` + _, _ = tx.Exec(ctx, abuseQuery, blockerID, blockedID, handle, actorIP) + + return tx.Commit(ctx) +} + +func (r *UserRepository) UnblockUser(ctx context.Context, blockerID, blockedID string) error { + query := `DELETE FROM public.blocks WHERE blocker_id = $1::uuid AND blocked_id = $2::uuid` + _, err := r.pool.Exec(ctx, query, blockerID, blockedID) + return err +} + +func (r *UserRepository) GetBlockedUsers(ctx context.Context, userID string) ([]models.Profile, error) { + query := ` + SELECT p.id, p.handle, p.display_name, p.avatar_url + FROM public.profiles p + JOIN public.blocks b ON p.id = b.blocked_id + WHERE b.blocker_id = $1::uuid + ` + rows, err := r.pool.Query(ctx, query, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var profiles []models.Profile + for rows.Next() { + var p models.Profile + if err := rows.Scan(&p.ID, &p.Handle, &p.DisplayName, &p.AvatarURL); err != nil { + return nil, err + } + profiles = append(profiles, p) + } + return profiles, nil +} + +func (r *UserRepository) CreateReport(ctx context.Context, report *models.Report) error { + query := ` + INSERT INTO public.reports (reporter_id, target_user_id, post_id, comment_id, violation_type, description, status) + VALUES ($1::uuid, $2::uuid, $3, $4, $5, $6, 'pending') + ` + _, err := r.pool.Exec(ctx, query, + report.ReporterID, + report.TargetUserID, + report.PostID, + report.CommentID, + report.ViolationType, + report.Description, + ) + return err +} + type ProfileStats struct { PostCount int `json:"post_count"` FollowerCount int `json:"follower_count"` @@ -570,32 +643,37 @@ func (r *UserRepository) GetSignalKeyBundle(ctx context.Context, userID string) func (r *UserRepository) GetPrivacySettings(ctx context.Context, userID string) (*models.PrivacySettings, error) { query := ` SELECT user_id, show_location, show_interests, profile_visibility, - posts_visibility, saved_visibility, follow_request_policy, updated_at + posts_visibility, saved_visibility, follow_request_policy, + default_post_visibility, is_private_profile, updated_at FROM public.profile_privacy_settings WHERE user_id = $1::uuid ` var ps models.PrivacySettings err := r.pool.QueryRow(ctx, query, userID).Scan( &ps.UserID, &ps.ShowLocation, &ps.ShowInterests, &ps.ProfileVisibility, - &ps.PostsVisibility, &ps.SavedVisibility, &ps.FollowRequestPolicy, &ps.UpdatedAt, + &ps.PostsVisibility, &ps.SavedVisibility, &ps.FollowRequestPolicy, + &ps.DefaultPostVisibility, &ps.IsPrivateProfile, &ps.UpdatedAt, ) if err != nil { if err.Error() == "no rows in result set" || err.Error() == "pgx: no rows in result set" { // Return default settings for new users (pointers required) uid, _ := uuid.Parse(userID) t := true + f := false pub := "public" priv := "private" anyone := "everyone" return &models.PrivacySettings{ - UserID: uid, - ShowLocation: &t, - ShowInterests: &t, - ProfileVisibility: &pub, - PostsVisibility: &pub, - SavedVisibility: &priv, - FollowRequestPolicy: &anyone, - UpdatedAt: time.Now(), + UserID: uid, + ShowLocation: &t, + ShowInterests: &t, + ProfileVisibility: &pub, + PostsVisibility: &pub, + SavedVisibility: &priv, + FollowRequestPolicy: &anyone, + DefaultPostVisibility: &pub, + IsPrivateProfile: &f, + UpdatedAt: time.Now(), }, nil } return nil, err @@ -607,8 +685,9 @@ func (r *UserRepository) UpdatePrivacySettings(ctx context.Context, ps *models.P query := ` INSERT INTO public.profile_privacy_settings ( user_id, show_location, show_interests, profile_visibility, - posts_visibility, saved_visibility, follow_request_policy, updated_at - ) VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, NOW()) + posts_visibility, saved_visibility, follow_request_policy, + default_post_visibility, is_private_profile, updated_at + ) VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, $8, $9, NOW()) ON CONFLICT (user_id) DO UPDATE SET show_location = COALESCE(EXCLUDED.show_location, profile_privacy_settings.show_location), show_interests = COALESCE(EXCLUDED.show_interests, profile_privacy_settings.show_interests), @@ -616,11 +695,14 @@ func (r *UserRepository) UpdatePrivacySettings(ctx context.Context, ps *models.P posts_visibility = COALESCE(EXCLUDED.posts_visibility, profile_privacy_settings.posts_visibility), saved_visibility = COALESCE(EXCLUDED.saved_visibility, profile_privacy_settings.saved_visibility), follow_request_policy = COALESCE(EXCLUDED.follow_request_policy, profile_privacy_settings.follow_request_policy), + default_post_visibility = COALESCE(EXCLUDED.default_post_visibility, profile_privacy_settings.default_post_visibility), + is_private_profile = COALESCE(EXCLUDED.is_private_profile, profile_privacy_settings.is_private_profile), updated_at = NOW() ` _, err := r.pool.Exec(ctx, query, ps.UserID, ps.ShowLocation, ps.ShowInterests, ps.ProfileVisibility, ps.PostsVisibility, ps.SavedVisibility, ps.FollowRequestPolicy, + ps.DefaultPostVisibility, ps.IsPrivateProfile, ) return err } @@ -971,3 +1053,11 @@ func (r *UserRepository) DeleteUser(ctx context.Context, userID uuid.UUID) error _, err := r.pool.Exec(ctx, query, userID) return err } +func (r *UserRepository) BlockUserByHandle(ctx context.Context, actorID string, handle string, actorIP string) error { + var targetID uuid.UUID + err := r.pool.QueryRow(ctx, "SELECT id FROM public.profiles WHERE handle = $1", handle).Scan(&targetID) + if err != nil { + return err + } + return r.BlockUser(ctx, actorID, targetID.String(), actorIP) +} diff --git a/go-backend/internal/services/moderation_service.go b/go-backend/internal/services/moderation_service.go new file mode 100644 index 0000000..ed7e7ca --- /dev/null +++ b/go-backend/internal/services/moderation_service.go @@ -0,0 +1,79 @@ +package services + +import ( + "bytes" + "context" + "encoding/json" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +type ModerationService struct { + pool *pgxpool.Pool + // In a real app, we would have API keys here +} + +func NewModerationService(pool *pgxpool.Pool) *ModerationService { + return &ModerationService{pool: pool} +} + +type ThreePoisonsScore struct { + Hate float64 `json:"hate"` + Greed float64 `json:"greed"` + Delusion float64 `json:"delusion"` +} + +func (s *ModerationService) AnalyzeContent(ctx context.Context, body string) (*ThreePoisonsScore, string, error) { + // PLACEHOLDER: In production, call OpenAI Moderation or HuggingFace + // For now, we use a simple rule-based mock to demonstrate the "Sanctuary" gates. + + score := &ThreePoisonsScore{ + Hate: 0.0, + Greed: 0.0, + Delusion: 0.0, + } + + // Simple mock rules + if containsAny(body, []string{"hate", "kill", "attack"}) { + score.Hate = 0.8 + } + if containsAny(body, []string{"buy", "crypto", "rich", "scam"}) { + score.Greed = 0.7 + } + if containsAny(body, []string{"fake", "truth", "conspiracy"}) { + score.Delusion = 0.6 + } + + flagReason := "" + if score.Hate > 0.5 { + flagReason = "hate" + } else if score.Greed > 0.5 { + flagReason = "greed" + } else if score.Delusion > 0.5 { + flagReason = "delusion" + } + + return score, flagReason, nil +} + +func (s *ModerationService) FlagPost(ctx context.Context, postID uuid.UUID, scores *ThreePoisonsScore, reason string) error { + scoresJSON, _ := json.Marshal(scores) + query := ` + INSERT INTO public.pending_moderation (post_id, flag_reason, scores) + VALUES ($1, $2, $3) + ` + _, err := s.pool.Exec(ctx, query, postID, reason, scoresJSON) + return err +} + +func containsAny(body string, terms []string) bool { + // Case insensitive check + lower := bytes.ToLower([]byte(body)) + for _, term := range terms { + if bytes.Contains(lower, []byte(term)) { + return true + } + } + return false +} diff --git a/go-backend/internal/services/notification_service.go b/go-backend/internal/services/notification_service.go index ed839b4..458be8e 100644 --- a/go-backend/internal/services/notification_service.go +++ b/go-backend/internal/services/notification_service.go @@ -2,9 +2,11 @@ package services import ( "context" + "encoding/json" "fmt" "github.com/google/uuid" + "github.com/patbritton/sojorn-backend/internal/models" "github.com/patbritton/sojorn-backend/internal/repository" "github.com/rs/zerolog/log" ) @@ -12,105 +14,446 @@ import ( type NotificationService struct { notifRepo *repository.NotificationRepository pushSvc *PushService + userRepo *repository.UserRepository } -func NewNotificationService(notifRepo *repository.NotificationRepository, pushSvc *PushService) *NotificationService { +func NewNotificationService(notifRepo *repository.NotificationRepository, pushSvc *PushService, userRepo *repository.UserRepository) *NotificationService { return &NotificationService{ notifRepo: notifRepo, pushSvc: pushSvc, + userRepo: userRepo, } } -func (s *NotificationService) CreateNotification(ctx context.Context, userID, actorID, notificationType string, postID *string, commentID *string, metadata map[string]interface{}) error { - // Parse UUIDs - // Validate UUIDs (for future use when we fix notification storage) - _, err := uuid.Parse(userID) - if err != nil { - return fmt.Errorf("invalid user ID: %w", err) +// ============================================================================ +// High-Level Notification Methods (Called by Handlers) +// ============================================================================ + +// NotifyLike sends a notification when someone likes a post +func (s *NotificationService) NotifyLike(ctx context.Context, postAuthorID, actorID, postID string, postType string) error { + if postAuthorID == actorID { + return nil // Don't notify self } - _, err = uuid.Parse(actorID) - if err != nil { - return fmt.Errorf("invalid actor ID: %w", err) + return s.sendNotification(ctx, models.PushNotificationRequest{ + UserID: uuid.MustParse(postAuthorID), + Type: models.NotificationTypeLike, + ActorID: uuid.MustParse(actorID), + PostID: uuidPtr(postID), + PostType: postType, + GroupKey: fmt.Sprintf("like:%s", postID), // Group likes on same post + Priority: models.PriorityNormal, + }) +} + +// NotifyComment sends a notification when someone comments on a post +func (s *NotificationService) NotifyComment(ctx context.Context, postAuthorID, actorID, postID, commentID string, postType string) error { + if postAuthorID == actorID { + return nil } - // Send push notification - if s.pushSvc != nil { - title, body, data := s.buildPushNotification(notificationType, metadata) - if err := s.pushSvc.SendPush(ctx, userID, title, body, data); err != nil { - log.Warn().Err(err).Str("user_id", userID).Msg("Failed to send push notification") + return s.sendNotification(ctx, models.PushNotificationRequest{ + UserID: uuid.MustParse(postAuthorID), + Type: models.NotificationTypeComment, + ActorID: uuid.MustParse(actorID), + PostID: uuidPtr(postID), + CommentID: uuidPtr(commentID), + PostType: postType, + GroupKey: fmt.Sprintf("comment:%s", postID), + Priority: models.PriorityNormal, + }) +} + +// NotifyReply sends a notification when someone replies to a comment +func (s *NotificationService) NotifyReply(ctx context.Context, commentAuthorID, actorID, postID, commentID string) error { + if commentAuthorID == actorID { + return nil + } + + return s.sendNotification(ctx, models.PushNotificationRequest{ + UserID: uuid.MustParse(commentAuthorID), + Type: models.NotificationTypeReply, + ActorID: uuid.MustParse(actorID), + PostID: uuidPtr(postID), + CommentID: uuidPtr(commentID), + Priority: models.PriorityNormal, + }) +} + +// NotifyMention sends notifications to all mentioned users +func (s *NotificationService) NotifyMention(ctx context.Context, actorID, postID string, text string) error { + mentionedUserIDs, err := s.notifRepo.ExtractMentions(ctx, text) + if err != nil || len(mentionedUserIDs) == 0 { + return err + } + + actorUUID := uuid.MustParse(actorID) + for _, userID := range mentionedUserIDs { + if userID == actorUUID { + continue // Don't notify self + } + + err := s.sendNotification(ctx, models.PushNotificationRequest{ + UserID: userID, + Type: models.NotificationTypeMention, + ActorID: actorUUID, + PostID: uuidPtr(postID), + Priority: models.PriorityHigh, // Mentions are high priority + }) + if err != nil { + log.Warn().Err(err).Str("user_id", userID.String()).Msg("Failed to send mention notification") } } return nil } -func (s *NotificationService) buildPushNotification(notificationType string, metadata map[string]interface{}) (title, body string, data map[string]string) { - actorName, _ := metadata["actor_name"].(string) +// NotifyFollow sends a notification when someone follows a user +func (s *NotificationService) NotifyFollow(ctx context.Context, followedUserID, followerID string, isPending bool) error { + notifType := models.NotificationTypeFollow + if isPending { + notifType = models.NotificationTypeFollowRequest + } - switch notificationType { - case "beacon_vouch": + return s.sendNotification(ctx, models.PushNotificationRequest{ + UserID: uuid.MustParse(followedUserID), + Type: notifType, + ActorID: uuid.MustParse(followerID), + Priority: models.PriorityNormal, + Metadata: map[string]interface{}{ + "follower_id": followerID, + }, + }) +} + +// NotifyFollowAccepted sends a notification when a follow request is accepted +func (s *NotificationService) NotifyFollowAccepted(ctx context.Context, followerID, acceptorID string) error { + return s.sendNotification(ctx, models.PushNotificationRequest{ + UserID: uuid.MustParse(followerID), + Type: models.NotificationTypeFollowAccept, + ActorID: uuid.MustParse(acceptorID), + Priority: models.PriorityNormal, + }) +} + +// NotifySave sends a notification when someone saves a post +func (s *NotificationService) NotifySave(ctx context.Context, postAuthorID, actorID, postID, postType string) error { + if postAuthorID == actorID { + return nil + } + + return s.sendNotification(ctx, models.PushNotificationRequest{ + UserID: uuid.MustParse(postAuthorID), + Type: models.NotificationTypeSave, + ActorID: uuid.MustParse(actorID), + PostID: uuidPtr(postID), + PostType: postType, + GroupKey: fmt.Sprintf("save:%s", postID), + Priority: models.PriorityLow, // Saves are lower priority + }) +} + +// NotifyMessage sends a notification for new chat messages +func (s *NotificationService) NotifyMessage(ctx context.Context, receiverID, senderID, conversationID string) error { + return s.sendNotification(ctx, models.PushNotificationRequest{ + UserID: uuid.MustParse(receiverID), + Type: models.NotificationTypeMessage, + ActorID: uuid.MustParse(senderID), + Priority: models.PriorityHigh, // Messages are high priority + Metadata: map[string]interface{}{ + "conversation_id": conversationID, + }, + }) +} + +// NotifyBeaconVouch sends a notification when someone vouches for a beacon +func (s *NotificationService) NotifyBeaconVouch(ctx context.Context, beaconAuthorID, actorID, beaconID string) error { + if beaconAuthorID == actorID { + return nil + } + + return s.sendNotification(ctx, models.PushNotificationRequest{ + UserID: uuid.MustParse(beaconAuthorID), + Type: models.NotificationTypeBeaconVouch, + ActorID: uuid.MustParse(actorID), + PostID: uuidPtr(beaconID), + PostType: "beacon", + GroupKey: fmt.Sprintf("beacon_vouch:%s", beaconID), + Priority: models.PriorityNormal, + }) +} + +// NotifyBeaconReport sends a notification when someone reports a beacon +func (s *NotificationService) NotifyBeaconReport(ctx context.Context, beaconAuthorID, actorID, beaconID string) error { + if beaconAuthorID == actorID { + return nil + } + + return s.sendNotification(ctx, models.PushNotificationRequest{ + UserID: uuid.MustParse(beaconAuthorID), + Type: models.NotificationTypeBeaconReport, + ActorID: uuid.MustParse(actorID), + PostID: uuidPtr(beaconID), + PostType: "beacon", + Priority: models.PriorityNormal, + }) +} + +// ============================================================================ +// Core Send Logic +// ============================================================================ + +func (s *NotificationService) sendNotification(ctx context.Context, req models.PushNotificationRequest) error { + // Check user preferences + shouldSend, err := s.notifRepo.ShouldSendPush(ctx, req.UserID.String(), req.Type) + if err != nil { + log.Warn().Err(err).Msg("Failed to check notification preferences") + } + + // Get actor details + actor, err := s.userRepo.GetProfileByID(ctx, req.ActorID.String()) + if err != nil { + log.Warn().Err(err).Msg("Failed to get actor profile for notification") + actor = &models.Profile{DisplayName: ptrString("Someone")} + } + if actor.DisplayName != nil { + req.ActorName = *actor.DisplayName + } + if actor.AvatarURL != nil { + req.ActorAvatar = *actor.AvatarURL + } + + // Create in-app notification record + notif := &models.Notification{ + UserID: req.UserID, + Type: req.Type, + ActorID: req.ActorID, + PostID: req.PostID, + IsRead: false, + Priority: req.Priority, + Metadata: s.buildMetadata(req), + } + if req.CommentID != nil { + notif.CommentID = req.CommentID + } + if req.GroupKey != "" { + notif.GroupKey = &req.GroupKey + } + + if err := s.notifRepo.CreateNotification(ctx, notif); err != nil { + log.Warn().Err(err).Msg("Failed to create in-app notification") + } + + // Send push notification if enabled + if shouldSend && s.pushSvc != nil { + title, body, data := s.buildPushPayload(req) + + // Get badge count for iOS/macOS + badge, _ := s.notifRepo.GetUnreadBadge(ctx, req.UserID.String()) + + err := s.pushSvc.SendPushWithBadge(ctx, req.UserID.String(), title, body, data, badge.TotalCount) + if err != nil { + log.Warn().Err(err).Str("user_id", req.UserID.String()).Msg("Failed to send push notification") + } + } + + return nil +} + +func (s *NotificationService) buildMetadata(req models.PushNotificationRequest) json.RawMessage { + data := map[string]interface{}{ + "actor_name": req.ActorName, + "post_type": req.PostType, + } + + if req.PostID != nil { + data["post_id"] = req.PostID.String() + } + if req.CommentID != nil { + data["comment_id"] = req.CommentID.String() + } + if req.PostPreview != "" { + data["post_preview"] = req.PostPreview + } + + for k, v := range req.Metadata { + data[k] = v + } + + bytes, _ := json.Marshal(data) + return bytes +} + +func (s *NotificationService) buildPushPayload(req models.PushNotificationRequest) (title, body string, data map[string]string) { + actorName := req.ActorName + if actorName == "" { + actorName = "Someone" + } + + data = map[string]string{ + "type": req.Type, + } + + if req.PostID != nil { + data["post_id"] = req.PostID.String() + } + if req.CommentID != nil { + data["comment_id"] = req.CommentID.String() + } + if req.PostType != "" { + data["post_type"] = req.PostType + } + + // Add target for navigation + target := s.getNavigationTarget(req.Type, req.PostType) + data["target"] = target + + // Copy metadata + for k, v := range req.Metadata { + if str, ok := v.(string); ok { + data[k] = str + } + } + + switch req.Type { + case models.NotificationTypeLike: + title = "New Like" + body = fmt.Sprintf("%s liked your %s", actorName, s.formatPostType(req.PostType)) + + case models.NotificationTypeComment: + title = "New Comment" + body = fmt.Sprintf("%s commented on your %s", actorName, s.formatPostType(req.PostType)) + + case models.NotificationTypeReply: + title = "New Reply" + body = fmt.Sprintf("%s replied to your comment", actorName) + + case models.NotificationTypeMention: + title = "You Were Mentioned" + body = fmt.Sprintf("%s mentioned you in a post", actorName) + + case models.NotificationTypeFollow: + title = "New Follower" + body = fmt.Sprintf("%s started following you", actorName) + data["follower_id"] = req.ActorID.String() + + case models.NotificationTypeFollowRequest: + title = "Follow Request" + body = fmt.Sprintf("%s wants to follow you", actorName) + data["follower_id"] = req.ActorID.String() + + case models.NotificationTypeFollowAccept: + title = "Request Accepted" + body = fmt.Sprintf("%s accepted your follow request", actorName) + + case models.NotificationTypeSave: + title = "Post Saved" + body = fmt.Sprintf("%s saved your %s", actorName, s.formatPostType(req.PostType)) + + case models.NotificationTypeMessage: + title = "New Message" + body = "You have a new message" + + case models.NotificationTypeBeaconVouch: title = "Beacon Vouched" body = fmt.Sprintf("%s vouched for your beacon", actorName) - data = map[string]string{ - "type": "beacon_vouch", - "beacon_id": getString(metadata, "beacon_id"), - "target": "beacon_map", // Deep link to map - } - case "beacon_report": + data["beacon_id"] = req.PostID.String() + + case models.NotificationTypeBeaconReport: title = "Beacon Reported" body = fmt.Sprintf("%s reported your beacon", actorName) - data = map[string]string{ - "type": "beacon_report", - "beacon_id": getString(metadata, "beacon_id"), - "target": "beacon_map", // Deep link to map - } - case "comment": - title = "New Comment" - postType := getString(metadata, "post_type") - if postType == "beacon" { - body = fmt.Sprintf("%s commented on your beacon", actorName) - data = map[string]string{ - "type": "comment", - "post_id": getString(metadata, "post_id"), - "target": "beacon_map", // Deep link to map for beacon comments - } - } else if postType == "quip" { - body = fmt.Sprintf("%s commented on your quip", actorName) - data = map[string]string{ - "type": "comment", - "post_id": getString(metadata, "post_id"), - "target": "quip_feed", // Deep link to quip feed - } - } else { - body = fmt.Sprintf("%s commented on your post", actorName) - data = map[string]string{ - "type": "comment", - "post_id": getString(metadata, "post_id"), - "target": "main_feed", // Deep link to main feed - } - } + data["beacon_id"] = req.PostID.String() + default: title = "Sojorn" body = "You have a new notification" - data = map[string]string{"type": notificationType} } return title, body, data } -// Helper functions -func parseNullableUUID(s *string) *uuid.UUID { - if s == nil { +func (s *NotificationService) getNavigationTarget(notifType, postType string) string { + switch notifType { + case models.NotificationTypeMessage: + return "secure_chat" + case models.NotificationTypeFollow, models.NotificationTypeFollowRequest, models.NotificationTypeFollowAccept: + return "profile" + case models.NotificationTypeBeaconVouch, models.NotificationTypeBeaconReport: + return "beacon_map" + default: + switch postType { + case "beacon": + return "beacon_map" + case "quip": + return "quip_feed" + default: + return "main_feed" + } + } +} + +func (s *NotificationService) formatPostType(postType string) string { + switch postType { + case "beacon": + return "beacon" + case "quip": + return "quip" + default: + return "post" + } +} + +// ============================================================================ +// Legacy Compatibility Method +// ============================================================================ + +// CreateNotification is the legacy method for backwards compatibility +func (s *NotificationService) CreateNotification(ctx context.Context, userID, actorID, notificationType string, postID *string, commentID *string, metadata map[string]interface{}) error { + actorName, _ := metadata["actor_name"].(string) + postType, _ := metadata["post_type"].(string) + + req := models.PushNotificationRequest{ + UserID: uuid.MustParse(userID), + Type: notificationType, + ActorID: uuid.MustParse(actorID), + PostType: postType, + Priority: models.PriorityNormal, + Metadata: metadata, + } + + if postID != nil { + req.PostID = uuidPtr(*postID) + } + if commentID != nil { + req.CommentID = uuidPtr(*commentID) + } + if actorName != "" { + req.ActorName = actorName + } + + return s.sendNotification(ctx, req) +} + +// ============================================================================ +// Helpers +// ============================================================================ + +func uuidPtr(s string) *uuid.UUID { + if s == "" { return nil } - u, err := uuid.Parse(*s) + u, err := uuid.Parse(s) if err != nil { return nil } return &u } +func ptrString(s string) *string { + return &s +} + +// Helper functions func getString(m map[string]interface{}, key string) string { if val, ok := m[key]; ok { if str, ok := val.(string); ok { diff --git a/go-backend/internal/services/push_service.go b/go-backend/internal/services/push_service.go index 750220e..975689e 100644 --- a/go-backend/internal/services/push_service.go +++ b/go-backend/internal/services/push_service.go @@ -26,11 +26,10 @@ func NewPushService(userRepo *repository.UserRepository, credentialsFile string) opt = option.WithCredentialsFile(credentialsFile) } else { log.Warn().Msg("Firebase credentials file not found, using default credentials") - opt = option.WithoutAuthentication() // Or handle differently + opt = option.WithoutAuthentication() } } else { - // Attempt to use logic suitable for Cloud Run/GCP or emulator - opt = option.WithCredentialsFile("firebase-service-account.json") // Default fallback + opt = option.WithCredentialsFile("firebase-service-account.json") } app, err := firebase.NewApp(ctx, nil, opt) @@ -51,17 +50,24 @@ func NewPushService(userRepo *repository.UserRepository, credentialsFile string) }, nil } +// SendPush sends a push notification to all user devices func (s *PushService) SendPush(ctx context.Context, userID, title, body string, data map[string]string) error { + return s.SendPushWithBadge(ctx, userID, title, body, data, 0) +} + +// SendPushWithBadge sends a push notification with badge count for iOS +func (s *PushService) SendPushWithBadge(ctx context.Context, userID, title, body string, data map[string]string, badge int) error { tokens, err := s.userRepo.GetFCMTokens(ctx, userID) if err != nil { return fmt.Errorf("failed to get FCM tokens: %w", err) } if len(tokens) == 0 { - return nil // No tokens, no push + log.Debug().Str("user_id", userID).Msg("No FCM tokens found for user") + return nil } - // Multicast message + // Build the message message := &messaging.MulticastMessage{ Tokens: tokens, Notification: &messaging.Notification{ @@ -72,10 +78,71 @@ func (s *PushService) SendPush(ctx context.Context, userID, title, body string, Android: &messaging.AndroidConfig{ Priority: "high", Notification: &messaging.AndroidNotification{ - Sound: "default", - ClickAction: "FLUTTER_NOTIFICATION_CLICK", + Sound: "default", + ClickAction: "FLUTTER_NOTIFICATION_CLICK", + ChannelID: "sojorn_notifications", + DefaultSound: true, + DefaultVibrateTimings: true, + NotificationCount: func() *int { c := badge; return &c }(), }, }, + APNS: &messaging.APNSConfig{ + Headers: map[string]string{ + "apns-priority": "10", + }, + Payload: &messaging.APNSPayload{ + Aps: &messaging.Aps{ + Sound: "default", + Badge: &badge, + MutableContent: true, + ContentAvailable: true, + }, + }, + }, + Webpush: &messaging.WebpushConfig{ + Notification: &messaging.WebpushNotification{ + Title: title, + Body: body, + Icon: "/icons/icon-192.png", + Badge: "/icons/badge-72.png", + Data: data, + }, + FCMOptions: &messaging.WebpushFCMOptions{ + Link: buildDeepLink(data), + }, + }, + } + + br, err := s.client.SendMulticast(ctx, message) + if err != nil { + return fmt.Errorf("error sending multicast message: %w", err) + } + + log.Debug(). + Str("user_id", userID). + Int("success_count", br.SuccessCount). + Int("failure_count", br.FailureCount). + Msg("Push notification sent") + + if br.FailureCount > 0 { + s.handleFailedTokens(ctx, userID, tokens, br.Responses) + } + + return nil +} + +// SendPushToTopics sends a push notification to a topic +func (s *PushService) SendPushToTopic(ctx context.Context, topic, title, body string, data map[string]string) error { + message := &messaging.Message{ + Topic: topic, + Notification: &messaging.Notification{ + Title: title, + Body: body, + }, + Data: data, + Android: &messaging.AndroidConfig{ + Priority: "high", + }, APNS: &messaging.APNSConfig{ Payload: &messaging.APNSPayload{ Aps: &messaging.Aps{ @@ -85,26 +152,102 @@ func (s *PushService) SendPush(ctx context.Context, userID, title, body string, }, } - br, err := s.client.SendMulticast(ctx, message) - if err != nil { - return fmt.Errorf("error sending multicast message: %w", err) + _, err := s.client.Send(ctx, message) + return err +} + +// SendSilentPush sends a data-only notification for badge updates +func (s *PushService) SendSilentPush(ctx context.Context, userID string, data map[string]string, badge int) error { + tokens, err := s.userRepo.GetFCMTokens(ctx, userID) + if err != nil || len(tokens) == 0 { + return err } - if br.FailureCount > 0 { - var failedTokens []string - for idx, resp := range br.Responses { - if !resp.Success { - if resp.Error != nil && messaging.IsRegistrationTokenNotRegistered(resp.Error) { - if err := s.userRepo.DeleteFCMToken(ctx, userID, tokens[idx]); err != nil { - log.Warn().Err(err).Str("user_id", userID).Msg("Failed to delete invalid FCM token") - } - continue + message := &messaging.MulticastMessage{ + Tokens: tokens, + Data: data, + Android: &messaging.AndroidConfig{ + Priority: "normal", + }, + APNS: &messaging.APNSConfig{ + Payload: &messaging.APNSPayload{ + Aps: &messaging.Aps{ + Badge: &badge, + ContentAvailable: true, + }, + }, + }, + } + + _, err = s.client.SendMulticast(ctx, message) + return err +} + +// handleFailedTokens removes invalid tokens from the database +func (s *PushService) handleFailedTokens(ctx context.Context, userID string, tokens []string, responses []*messaging.SendResponse) { + var invalidTokens []string + + for idx, resp := range responses { + if !resp.Success { + if resp.Error != nil && messaging.IsRegistrationTokenNotRegistered(resp.Error) { + invalidTokens = append(invalidTokens, tokens[idx]) + if err := s.userRepo.DeleteFCMToken(ctx, userID, tokens[idx]); err != nil { + log.Warn().Err(err).Str("user_id", userID).Msg("Failed to delete invalid FCM token") } - failedTokens = append(failedTokens, tokens[idx]) + } else if resp.Error != nil { + log.Warn(). + Err(resp.Error). + Str("user_id", userID). + Str("token", tokens[idx][:min(20, len(tokens[idx]))]). + Msg("FCM send failed for token") } } - log.Warn().Int("failure_count", br.FailureCount).Strs("failed_tokens", failedTokens).Msg("Some push notifications failed") } - return nil + if len(invalidTokens) > 0 { + log.Info(). + Str("user_id", userID). + Int("count", len(invalidTokens)). + Msg("Cleaned up invalid FCM tokens") + } +} + +// buildDeepLink creates a deep link URL from notification data +func buildDeepLink(data map[string]string) string { + target := data["target"] + baseURL := "https://gosojorn.com" + + switch target { + case "secure_chat": + if convID, ok := data["conversation_id"]; ok { + return fmt.Sprintf("%s/chat/%s", baseURL, convID) + } + return baseURL + "/chat" + case "profile": + if followerID, ok := data["follower_id"]; ok { + return fmt.Sprintf("%s/u/%s", baseURL, followerID) + } + return baseURL + "/profile" + case "beacon_map": + return baseURL + "/beacon" + case "quip_feed": + return baseURL + "/quips" + case "thread_view": + if postID, ok := data["post_id"]; ok { + return fmt.Sprintf("%s/p/%s", baseURL, postID) + } + return baseURL + default: + if postID, ok := data["post_id"]; ok { + return fmt.Sprintf("%s/p/%s", baseURL, postID) + } + return baseURL + } +} + +func min(a, b int) int { + if a < b { + return a + } + return b } diff --git a/go-backend/seed_suggested.sql b/go-backend/seed_suggested.sql new file mode 100644 index 0000000..3769b7f --- /dev/null +++ b/go-backend/seed_suggested.sql @@ -0,0 +1,5 @@ +INSERT INTO suggested_users (user_id, reason, score) VALUES +('e40f6513-0aae-40b4-8644-3edb1fe6a4e0', 'popular', 100), +('be03a13a-4067-4b2e-829c-811caac2b5fb', 'popular', 95), +('f4f341e6-42eb-45ac-8ce1-44d49388016c', 'new_creator', 80) +ON CONFLICT DO NOTHING; diff --git a/sojorn_app/lib/models/profile_privacy_settings.dart b/sojorn_app/lib/models/profile_privacy_settings.dart index 4754291..60fcc4b 100644 --- a/sojorn_app/lib/models/profile_privacy_settings.dart +++ b/sojorn_app/lib/models/profile_privacy_settings.dart @@ -4,6 +4,8 @@ class ProfilePrivacySettings { final String postsVisibility; final String savedVisibility; final String followRequestPolicy; + final String defaultPostVisibility; + final bool isPrivateProfile; const ProfilePrivacySettings({ required this.userId, @@ -11,6 +13,8 @@ class ProfilePrivacySettings { required this.postsVisibility, required this.savedVisibility, required this.followRequestPolicy, + required this.defaultPostVisibility, + required this.isPrivateProfile, }); factory ProfilePrivacySettings.fromJson(Map json) { @@ -20,6 +24,8 @@ class ProfilePrivacySettings { postsVisibility: json['posts_visibility'] as String? ?? 'public', savedVisibility: json['saved_visibility'] as String? ?? 'private', followRequestPolicy: json['follow_request_policy'] as String? ?? 'everyone', + defaultPostVisibility: json['default_post_visibility'] as String? ?? 'public', + isPrivateProfile: json['is_private_profile'] as bool? ?? false, ); } @@ -30,6 +36,8 @@ class ProfilePrivacySettings { 'posts_visibility': postsVisibility, 'saved_visibility': savedVisibility, 'follow_request_policy': followRequestPolicy, + 'default_post_visibility': defaultPostVisibility, + 'is_private_profile': isPrivateProfile, }; } @@ -38,6 +46,8 @@ class ProfilePrivacySettings { String? postsVisibility, String? savedVisibility, String? followRequestPolicy, + String? defaultPostVisibility, + bool? isPrivateProfile, }) { return ProfilePrivacySettings( userId: userId, @@ -45,6 +55,8 @@ class ProfilePrivacySettings { postsVisibility: postsVisibility ?? this.postsVisibility, savedVisibility: savedVisibility ?? this.savedVisibility, followRequestPolicy: followRequestPolicy ?? this.followRequestPolicy, + defaultPostVisibility: defaultPostVisibility ?? this.defaultPostVisibility, + isPrivateProfile: isPrivateProfile ?? this.isPrivateProfile, ); } @@ -55,6 +67,8 @@ class ProfilePrivacySettings { postsVisibility: 'public', savedVisibility: 'private', followRequestPolicy: 'everyone', + defaultPostVisibility: 'public', + isPrivateProfile: false, ); } } diff --git a/sojorn_app/lib/models/user_settings.dart b/sojorn_app/lib/models/user_settings.dart index 39df2b8..5ed95d6 100644 --- a/sojorn_app/lib/models/user_settings.dart +++ b/sojorn_app/lib/models/user_settings.dart @@ -1,15 +1,39 @@ class UserSettings { final String userId; + final String theme; + final String language; + final bool notificationsEnabled; + final bool emailNotifications; + final bool pushNotifications; + final String contentFilterLevel; + final bool autoPlayVideos; + final bool dataSaverMode; final int? defaultPostTtl; const UserSettings({ required this.userId, + this.theme = 'system', + this.language = 'en', + this.notificationsEnabled = true, + this.emailNotifications = true, + this.pushNotifications = true, + this.contentFilterLevel = 'medium', + this.autoPlayVideos = true, + this.dataSaverMode = false, this.defaultPostTtl, }); factory UserSettings.fromJson(Map json) { return UserSettings( userId: json['user_id'] as String, + theme: json['theme'] as String? ?? 'system', + language: json['language'] as String? ?? 'en', + notificationsEnabled: json['notifications_enabled'] as bool? ?? true, + emailNotifications: json['email_notifications'] as bool? ?? true, + pushNotifications: json['push_notifications'] as bool? ?? true, + contentFilterLevel: json['content_filter_level'] as String? ?? 'medium', + autoPlayVideos: json['auto_play_videos'] as bool? ?? true, + dataSaverMode: json['data_saver_mode'] as bool? ?? false, defaultPostTtl: _parseIntervalHours(json['default_post_ttl']), ); } @@ -17,15 +41,39 @@ class UserSettings { Map toJson() { return { 'user_id': userId, - 'default_post_ttl': defaultPostTtl != null ? '${defaultPostTtl} hours' : null, + 'theme': theme, + 'language': language, + 'notifications_enabled': notificationsEnabled, + 'email_notifications': emailNotifications, + 'push_notifications': pushNotifications, + 'content_filter_level': contentFilterLevel, + 'auto_play_videos': autoPlayVideos, + 'data_saver_mode': dataSaverMode, + 'default_post_ttl': defaultPostTtl, }; } UserSettings copyWith({ + String? theme, + String? language, + bool? notificationsEnabled, + bool? emailNotifications, + bool? pushNotifications, + String? contentFilterLevel, + bool? autoPlayVideos, + bool? dataSaverMode, int? defaultPostTtl, }) { return UserSettings( userId: userId, + theme: theme ?? this.theme, + language: language ?? this.language, + notificationsEnabled: notificationsEnabled ?? this.notificationsEnabled, + emailNotifications: emailNotifications ?? this.emailNotifications, + pushNotifications: pushNotifications ?? this.pushNotifications, + contentFilterLevel: contentFilterLevel ?? this.contentFilterLevel, + autoPlayVideos: autoPlayVideos ?? this.autoPlayVideos, + dataSaverMode: dataSaverMode ?? this.dataSaverMode, defaultPostTtl: defaultPostTtl ?? this.defaultPostTtl, ); } @@ -38,9 +86,9 @@ class UserSettings { final trimmed = value.trim(); if (trimmed.isEmpty) return null; - final dayMatch = RegExp(r'(\\d+)\\s+day').firstMatch(trimmed); - final hourMatch = RegExp(r'(\\d+)\\s+hour').firstMatch(trimmed); - final timeMatch = RegExp(r'(\\d{1,2}):(\\d{2}):(\\d{2})').firstMatch(trimmed); + final dayMatch = RegExp(r'(\d+)\s+day').firstMatch(trimmed); + final hourMatch = RegExp(r'(\d+)\s+hour').firstMatch(trimmed); + final timeMatch = RegExp(r'(\d{1,2}):(\d{2}):(\d{2})').firstMatch(trimmed); var totalHours = 0; if (dayMatch != null) { diff --git a/sojorn_app/lib/providers/settings_provider.dart b/sojorn_app/lib/providers/settings_provider.dart new file mode 100644 index 0000000..f2a878b --- /dev/null +++ b/sojorn_app/lib/providers/settings_provider.dart @@ -0,0 +1,121 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../models/profile.dart'; +import '../models/profile_privacy_settings.dart'; +import '../models/user_settings.dart'; +import '../models/trust_state.dart'; +import '../services/api_service.dart'; + +class SettingsState { + final Profile? profile; + final ProfilePrivacySettings? privacy; + final UserSettings? user; + final TrustState? trust; + final bool isLoading; + final String? error; + + SettingsState({ + this.profile, + this.privacy, + this.user, + this.trust, + this.isLoading = false, + this.error, + }); + + SettingsState copyWith({ + Profile? profile, + ProfilePrivacySettings? privacy, + UserSettings? user, + TrustState? trust, + bool? isLoading, + String? error, + }) { + return SettingsState( + profile: profile ?? this.profile, + privacy: privacy ?? this.privacy, + user: user ?? this.user, + trust: trust ?? this.trust, + isLoading: isLoading ?? this.isLoading, + error: error, + ); + } +} + +class SettingsNotifier extends StateNotifier { + final ApiService _apiService; + + SettingsNotifier(this._apiService) : super(SettingsState()) { + refresh(); + } + + Future refresh() async { + state = state.copyWith(isLoading: true, error: null); + try { + final profileJson = await _apiService.callGoApi('/profile', method: 'GET'); + final privacyJson = await _apiService.callGoApi('/settings/privacy', method: 'GET'); + final userJson = await _apiService.callGoApi('/settings/user', method: 'GET'); + + // Trust state might be nested or a separate call + // Based on my knowledge, it's often fetched with the profile or /trust-state + TrustState? trust; + try { + final trustJson = await _apiService.callGoApi('/profile/trust-state', method: 'GET'); + trust = TrustState.fromJson(trustJson); + } catch (_) { + // Fallback or ignore if not available + } + + state = state.copyWith( + profile: Profile.fromJson(profileJson['profile']), + privacy: ProfilePrivacySettings.fromJson(privacyJson), + user: UserSettings.fromJson(userJson), + trust: trust, + isLoading: false, + ); + } catch (e) { + state = state.copyWith(isLoading: false, error: e.toString()); + } + } + + Future updatePrivacy(ProfilePrivacySettings newPrivacy) async { + final oldPrivacy = state.privacy; + state = state.copyWith(privacy: newPrivacy); + try { + await _apiService.callGoApi('/settings/privacy', method: 'PATCH', body: newPrivacy.toJson()); + } catch (e) { + state = state.copyWith(privacy: oldPrivacy, error: e.toString()); + } + } + + Future updateUser(UserSettings newUser) async { + final oldUser = state.user; + state = state.copyWith(user: newUser); + try { + await _apiService.callGoApi('/settings/user', method: 'PATCH', body: newUser.toJson()); + } catch (e) { + state = state.copyWith(user: oldUser, error: e.toString()); + } + } + + Future updateProfile(Profile newProfile) async { + final oldProfile = state.profile; + state = state.copyWith(profile: newProfile); + try { + // Need to map updateProfile arguments or use map + await _apiService.callGoApi('/profile', method: 'PATCH', body: { + 'display_name': newProfile.displayName, + 'bio': newProfile.bio, + 'location': newProfile.location, + 'website': newProfile.website, + 'avatar_url': newProfile.avatarUrl, + 'cover_url': newProfile.coverUrl, + }); + } catch (e) { + state = state.copyWith(profile: oldProfile, error: e.toString()); + } + } +} + +final settingsProvider = StateNotifierProvider((ref) { + return SettingsNotifier(ApiService.instance); +}); diff --git a/sojorn_app/lib/providers/upload_provider.dart b/sojorn_app/lib/providers/upload_provider.dart new file mode 100644 index 0000000..4af783c --- /dev/null +++ b/sojorn_app/lib/providers/upload_provider.dart @@ -0,0 +1,39 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class UploadProgress { + final double progress; + final bool isUploading; + final String? error; + + UploadProgress({this.progress = 0, this.isUploading = false, this.error}); +} + +class UploadNotifier extends StateNotifier { + UploadNotifier() : super(UploadProgress()); + + void setProgress(double progress) { + state = UploadProgress(progress: progress, isUploading = true); + } + + void start() { + state = UploadProgress(progress: 0, isUploading: true); + } + + void complete() { + state = UploadProgress(progress: 1, isUploading: false); + // Reset after success + Future.delayed(const Duration(seconds: 2), () { + if (state.progress == 1 && !state.isUploading) { + state = UploadProgress(); + } + }); + } + + void fail(String error) { + state = UploadProgress(progress: 0, isUploading: false, error: error); + } +} + +final uploadProvider = StateNotifierProvider((ref) { + return UploadNotifier(); +}); diff --git a/sojorn_app/lib/routes/app_routes.dart b/sojorn_app/lib/routes/app_routes.dart index 23d7340..a0ebe31 100644 --- a/sojorn_app/lib/routes/app_routes.dart +++ b/sojorn_app/lib/routes/app_routes.dart @@ -15,7 +15,9 @@ import '../screens/quips/create/quip_creation_flow.dart'; import '../screens/quips/feed/quips_feed_screen.dart'; import '../screens/profile/profile_screen.dart'; import '../screens/profile/viewable_profile_screen.dart'; +import '../screens/profile/blocked_users_screen.dart'; import '../screens/auth/auth_gate.dart'; +import '../screens/discover/discover_screen.dart'; import '../screens/secure_chat/secure_chat_full_screen.dart'; /// App routing config (GoRouter). @@ -79,19 +81,8 @@ class AppRoutes { StatefulShellBranch( routes: [ GoRoute( - path: beaconPrefix, - builder: (_, state) { - final latParam = state.uri.queryParameters['lat']; - final longParam = state.uri.queryParameters['long']; - final lat = latParam != null ? double.tryParse(latParam) : null; - final long = longParam != null ? double.tryParse(longParam) : null; - - if (lat != null && long != null) { - return BeaconScreen(initialMapCenter: LatLng(lat, long)); - } - - return const BeaconScreen(); - }, + path: '/discover', + builder: (_, __) => const DiscoverScreen(), ), ], ), @@ -108,6 +99,12 @@ class AppRoutes { GoRoute( path: profile, builder: (_, __) => const ProfileScreen(), + routes: [ + GoRoute( + path: 'blocked', + builder: (_, __) => const BlockedUsersScreen(), + ), + ], ), ], ), diff --git a/sojorn_app/lib/screens/discover/discover_screen.dart b/sojorn_app/lib/screens/discover/discover_screen.dart new file mode 100644 index 0000000..9f7b220 --- /dev/null +++ b/sojorn_app/lib/screens/discover/discover_screen.dart @@ -0,0 +1,743 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../providers/api_provider.dart'; +import '../../models/search_results.dart'; +import '../../models/post.dart'; +import '../../theme/app_theme.dart'; +import '../../widgets/sojorn_post_card.dart'; +import '../../widgets/media/signed_media_image.dart'; +import '../profile/viewable_profile_screen.dart'; +import '../compose/compose_screen.dart'; +import '../post/post_detail_screen.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Model for discover page data +class DiscoverData { + final List topTags; + final List popularPosts; + + DiscoverData({ + required this.topTags, + required this.popularPosts, + }); + + factory DiscoverData.fromJson(Map json) { + return DiscoverData( + topTags: (json['top_tags'] as List? ?? []) + .map((e) => Hashtag.fromJson(e)) + .toList(), + popularPosts: (json['popular_posts'] as List? ?? []) + .map((e) => Post.fromJson(e)) + .toList(), + ); + } +} + +class Hashtag { + final String id; + final String name; + final String displayName; + final int useCount; + final bool isTrending; + + Hashtag({ + required this.id, + required this.name, + required this.displayName, + required this.useCount, + this.isTrending = false, + }); + + factory Hashtag.fromJson(Map json) { + return Hashtag( + id: json['id'] ?? '', + name: json['name'] ?? '', + displayName: json['display_name'] ?? json['name'] ?? '', + useCount: json['use_count'] ?? 0, + isTrending: json['is_trending'] ?? false, + ); + } +} + +class DiscoverScreen extends ConsumerStatefulWidget { + final String? initialQuery; + + const DiscoverScreen({super.key, this.initialQuery}); + + @override + ConsumerState createState() => _DiscoverScreenState(); +} + +class _DiscoverScreenState extends ConsumerState { + final TextEditingController searchController = TextEditingController(); + final FocusNode focusNode = FocusNode(); + Timer? debounceTimer; + bool isLoadingSearch = false; + bool isLoadingDiscover = true; + bool hasSearched = false; + SearchResults? searchResults; + DiscoverData? discoverData; + List recentSearches = []; + int _searchEpoch = 0; + final Map> _postFutures = {}; + + static const Duration debounceDuration = Duration(milliseconds: 250); + + @override + void initState() { + super.initState(); + loadRecentSearches(); + loadDiscoverData(); + + if (widget.initialQuery != null) { + final query = widget.initialQuery!; + searchController.text = query; + Future.delayed(const Duration(milliseconds: 100), () { + performSearch(query); + }); + } + } + + @override + void dispose() { + searchController.dispose(); + focusNode.dispose(); + debounceTimer?.cancel(); + super.dispose(); + } + + Future loadDiscoverData() async { + setState(() => isLoadingDiscover = true); + try { + final apiService = ref.read(apiServiceProvider); + final response = await apiService.get('/discover'); + if (!mounted) return; + + setState(() { + discoverData = DiscoverData.fromJson(response); + isLoadingDiscover = false; + }); + } catch (e) { + debugPrint('Error loading discover data: $e'); + if (mounted) { + setState(() => isLoadingDiscover = false); + } + } + } + + Future loadRecentSearches() async { + try { + final prefs = await SharedPreferences.getInstance(); + final recentJson = prefs.getStringList('recent_searches') ?? []; + setState(() { + recentSearches = recentJson + .map((e) => RecentSearch.fromJson(jsonDecode(e))) + .toList(); + }); + } catch (e) { + debugPrint('Error loading recent searches: $e'); + } + } + + Future saveRecentSearch(RecentSearch search) async { + try { + recentSearches.removeWhere( + (s) => s.text.toLowerCase() == search.text.toLowerCase()); + recentSearches.insert(0, search); + if (recentSearches.length > 10) { + recentSearches = recentSearches.sublist(0, 10); + } + final prefs = await SharedPreferences.getInstance(); + final recentJson = + recentSearches.map((e) => jsonEncode(e.toJson())).toList(); + await prefs.setStringList('recent_searches', recentJson); + + if (mounted) setState(() {}); + } catch (e) { + debugPrint('Error saving recent search: $e'); + } + } + + Future clearRecentSearches() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('recent_searches'); + setState(() { + recentSearches = []; + }); + } catch (e) { + debugPrint('Error clearing recent searches: $e'); + } + } + + void onSearchChanged(String value) { + debounceTimer?.cancel(); + + final query = value.trim(); + if (query.isEmpty) { + setState(() { + searchResults = null; + hasSearched = false; + isLoadingSearch = false; + }); + return; + } + + debounceTimer = Timer(debounceDuration, () { + performSearch(query); + }); + } + + Future performSearch(String query) async { + final normalizedQuery = query.trim(); + if (normalizedQuery.isEmpty) return; + final requestId = ++_searchEpoch; + + setState(() { + isLoadingSearch = true; + hasSearched = true; + }); + + try { + final apiService = ref.read(apiServiceProvider); + final results = await apiService.search(normalizedQuery); + if (!mounted || requestId != _searchEpoch) return; + + if (results.users.isNotEmpty) { + await saveRecentSearch(RecentSearch( + id: results.users.first.id, + text: results.users.first.username, + searchedAt: DateTime.now(), + type: RecentSearchType.user, + )); + } else if (results.tags.isNotEmpty) { + await saveRecentSearch(RecentSearch( + id: 'tag_${results.tags.first.tag}', + text: results.tags.first.tag, + searchedAt: DateTime.now(), + type: RecentSearchType.tag, + )); + } + + if (!mounted || requestId != _searchEpoch) return; + setState(() { + searchResults = results; + isLoadingSearch = false; + }); + } catch (e) { + if (!mounted || requestId != _searchEpoch) return; + setState(() { + isLoadingSearch = false; + searchResults = SearchResults(users: [], tags: [], posts: []); + }); + } + } + + void clearSearch() { + searchController.clear(); + setState(() { + searchResults = null; + hasSearched = false; + isLoadingSearch = false; + }); + focusNode.unfocus(); + } + + void _navigateToHashtag(String name) { + final query = '#$name'; + searchController.text = query; + performSearch(query); + } + + void _navigateToProfile(String handle) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ViewableProfileScreen(handle: handle), + ), + ); + } + + Future _getPostFuture(String postId) { + return _postFutures.putIfAbsent(postId, () { + final apiService = ref.read(apiServiceProvider); + return apiService.getPostById(postId); + }); + } + + void _retryPostLoad(String postId) { + _postFutures.remove(postId); + if (mounted) setState(() {}); + } + + void _openPostDetail(Post post) { + Navigator.of(context, rootNavigator: true).push( + MaterialPageRoute( + builder: (_) => PostDetailScreen(post: post), + ), + ); + } + + void _openChainComposer(Post post) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ComposeScreen(chainParentPost: post), + fullscreenDialog: true, + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppTheme.scaffoldBg, + body: SafeArea( + child: Column( + children: [ + _buildHeader(), + Expanded( + child: hasSearched ? _buildSearchResults() : _buildDiscoverContent(), + ), + ], + ), + ), + ); + } + + Widget _buildHeader() { + return Container( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + decoration: BoxDecoration( + color: AppTheme.cardSurface, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + Expanded(child: _buildSearchField()), + if (hasSearched) + IconButton( + icon: Icon(Icons.close, color: AppTheme.egyptianBlue), + onPressed: clearSearch, + ), + ], + ), + ); + } + + Widget _buildSearchField() { + return Container( + height: 44, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: AppTheme.scaffoldBg, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppTheme.egyptianBlue.withOpacity(0.3)), + ), + child: Row( + children: [ + Icon(Icons.search, + color: AppTheme.egyptianBlue.withOpacity(0.6), size: 20), + const SizedBox(width: 10), + Expanded( + child: TextField( + controller: searchController, + focusNode: focusNode, + decoration: InputDecoration( + hintText: 'Search people, hashtags, posts...', + hintStyle: TextStyle(color: AppTheme.egyptianBlue.withOpacity(0.5)), + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + style: AppTheme.bodyMedium, + onChanged: onSearchChanged, + textInputAction: TextInputAction.search, + onSubmitted: (value) { + if (value.trim().isNotEmpty) { + performSearch(value.trim()); + } + }, + ), + ), + ], + ), + ); + } + + Widget _buildDiscoverContent() { + if (isLoadingDiscover) { + return const Center(child: CircularProgressIndicator()); + } + + return RefreshIndicator( + onRefresh: loadDiscoverData, + child: CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 12), + child: Text( + 'Top Trending', + style: AppTheme.labelLarge.copyWith( + color: AppTheme.navyBlue, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + SliverToBoxAdapter( + child: SizedBox( + height: 44, + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: discoverData?.topTags.length ?? 0, + itemBuilder: (context, index) { + final tag = discoverData!.topTags[index]; + return Padding( + padding: const EdgeInsets.only(right: 10), + child: ActionChip( + label: Text('#${tag.displayName}'), + labelStyle: TextStyle( + color: AppTheme.royalPurple, + fontWeight: FontWeight.w600, + fontSize: 14, + ), + backgroundColor: AppTheme.royalPurple.withOpacity(0.1), + side: BorderSide.none, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + onPressed: () => _navigateToHashtag(tag.name), + ), + ); + }, + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 12), + child: Text( + 'Popular Now', + style: AppTheme.labelLarge.copyWith( + color: AppTheme.navyBlue, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (discoverData?.popularPosts.isEmpty ?? true) + const SliverFillRemaining( + child: Center( + child: Text('No popular posts yet.'), + ), + ) + else + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final post = discoverData!.popularPosts[index]; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: sojornPostCard( + post: post, + onTap: () => _openPostDetail(post), + onChain: () => _openChainComposer(post), + ), + ); + }, + childCount: discoverData!.popularPosts.length, + ), + ), + const SliverPadding(padding: EdgeInsets.only(bottom: 50)), + ], + ), + ); + } + + Widget _buildSearchResults() { + if (isLoadingSearch) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 40, + height: 40, + child: CircularProgressIndicator( + strokeWidth: 3, + color: AppTheme.royalPurple, + ), + ), + const SizedBox(height: 16), + Text('Searching...', + style: AppTheme.labelMedium.copyWith(color: AppTheme.egyptianBlue)), + ], + ), + ); + } + + if (searchResults == null || + (searchResults!.users.isEmpty && + searchResults!.tags.isEmpty && + searchResults!.posts.isEmpty)) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.search_off, + size: 64, color: AppTheme.egyptianBlue.withOpacity(0.5)), + const SizedBox(height: 16), + Text('No results found', + style: AppTheme.headlineSmall + .copyWith(color: AppTheme.navyText.withOpacity(0.7))), + const SizedBox(height: 8), + Text('Try a different search term', + style: AppTheme.bodyMedium.copyWith(color: AppTheme.egyptianBlue)), + ], + ), + ); + } + + return ListView( + padding: const EdgeInsets.symmetric(vertical: 16), + children: [ + if (searchResults!.users.isNotEmpty) ...[ + _buildSectionHeader('People', icon: Icons.people), + SizedBox( + height: 100, + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: searchResults!.users.length, + itemBuilder: (context, index) { + final user = searchResults!.users[index]; + return _buildUserResultItem(user); + }, + ), + ), + const SizedBox(height: 24), + ], + if (searchResults!.tags.isNotEmpty) ...[ + _buildSectionHeader('Hashtags', icon: Icons.tag), + ...searchResults!.tags.map((tag) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: _buildTagResultItem(tag), + )), + const SizedBox(height: 24), + ], + if (searchResults!.posts.isNotEmpty) ...[ + _buildSectionHeader('Posts', icon: Icons.article), + ...searchResults!.posts.map((post) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: _buildPostResultItem(post), + )), + ], + ], + ); + } + + Widget _buildSectionHeader(String title, {IconData? icon}) { + return Padding( + padding: const EdgeInsets.only(left: 16, right: 16, bottom: 12), + child: Row( + children: [ + if (icon != null) ...[ + Icon(icon, size: 20, color: AppTheme.royalPurple), + const SizedBox(width: 8), + ], + Text( + title, + style: AppTheme.labelMedium.copyWith( + color: AppTheme.navyBlue, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } + + Widget _buildUserResultItem(SearchUser user) { + return GestureDetector( + onTap: () => _navigateToProfile(user.username), + child: Container( + width: 80, + margin: const EdgeInsets.only(right: 12), + child: Column( + children: [ + CircleAvatar( + radius: 30, + backgroundColor: AppTheme.royalPurple.withOpacity(0.2), + child: user.avatarUrl != null + ? ClipRRect( + borderRadius: BorderRadius.circular(30), + child: SignedMediaImage( + url: user.avatarUrl!, + width: 60, + height: 60, + fit: BoxFit.cover, + ), + ) + : Text( + user.displayName.isNotEmpty + ? user.displayName[0].toUpperCase() + : '?', + style: TextStyle( + color: AppTheme.royalPurple, + fontWeight: FontWeight.bold, + fontSize: 22), + ), + ), + const SizedBox(height: 6), + Text( + user.displayName, + style: TextStyle( + color: AppTheme.navyText, + fontSize: 12, + fontWeight: FontWeight.w500), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + '@${user.username}', + style: TextStyle( + color: AppTheme.egyptianBlue.withOpacity(0.7), fontSize: 11), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ); + } + + Widget _buildTagResultItem(SearchTag tag) { + return GestureDetector( + onTap: () => _navigateToHashtag(tag.tag), + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: AppTheme.cardSurface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppTheme.egyptianBlue.withOpacity(0.2)), + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: AppTheme.royalPurple.withOpacity(0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(Icons.tag, color: AppTheme.royalPurple, size: 20), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('#${tag.tag}', + style: TextStyle( + color: AppTheme.navyText, + fontWeight: FontWeight.w600, + fontSize: 15)), + Text('${tag.count} posts', + style: TextStyle( + color: AppTheme.egyptianBlue.withOpacity(0.7), + fontSize: 13)), + ], + ), + ), + Icon(Icons.chevron_right, + color: AppTheme.egyptianBlue.withOpacity(0.5)), + ], + ), + ), + ); + } + + Widget _buildPostResultItem(SearchPost post) { + return FutureBuilder( + future: _getPostFuture(post.id), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.cardSurface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppTheme.egyptianBlue.withOpacity(0.2)), + ), + child: Row( + children: [ + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppTheme.royalPurple, + ), + ), + const SizedBox(width: 12), + Text('Loading post...', style: AppTheme.bodyMedium), + ], + ), + ); + } + + if (snapshot.hasError || !snapshot.hasData) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.cardSurface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppTheme.egyptianBlue.withOpacity(0.2)), + ), + child: Row( + children: [ + Icon(Icons.error_outline, + color: AppTheme.egyptianBlue.withOpacity(0.6)), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Unable to load post', + style: AppTheme.bodyMedium, + ), + ), + TextButton( + onPressed: () => _retryPostLoad(post.id), + child: Text('Retry', + style: AppTheme.labelMedium + .copyWith(color: AppTheme.royalPurple)), + ), + ], + ), + ); + } + + final fullPost = snapshot.data!; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: sojornPostCard( + post: fullPost, + onTap: () => _openPostDetail(fullPost), + onChain: () => _openChainComposer(fullPost), + ), + ); + }, + ); + } +} diff --git a/sojorn_app/lib/screens/home/home_shell.dart b/sojorn_app/lib/screens/home/home_shell.dart index dea3637..101d9a2 100644 --- a/sojorn_app/lib/screens/home/home_shell.dart +++ b/sojorn_app/lib/screens/home/home_shell.dart @@ -6,10 +6,13 @@ import '../../theme/app_theme.dart'; import '../notifications/notifications_screen.dart'; import '../compose/compose_screen.dart'; import '../search/search_screen.dart'; +import '../discover/discover_screen.dart'; import '../beacon/beacon_screen.dart'; import '../quips/create/quip_creation_flow.dart'; import '../secure_chat/secure_chat_full_screen.dart'; import '../../widgets/radial_menu_overlay.dart'; +import '../../providers/quip_upload_provider.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; /// Root shell for the main tabs. The active tab is controlled by GoRouter's /// [StatefulNavigationShell] so navigation state and tab selection stay in sync. @@ -92,25 +95,53 @@ class _HomeShellState extends State with WidgetsBindingObserver { offset: const Offset(0, 12), child: GestureDetector( onTap: () => setState(() => _isRadialMenuVisible = !_isRadialMenuVisible), - child: Container( - width: 56, - height: 56, - decoration: BoxDecoration( - color: AppTheme.navyBlue, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: AppTheme.navyBlue.withOpacity(0.4), - blurRadius: 12, - offset: const Offset(0, 4), + child: Stack( + alignment: Alignment.center, + children: [ + // Outer Ring for Upload Progress + Consumer( + builder: (context, ref, child) { + final upload = ref.watch(quipUploadProvider); + + if (!upload.isUploading && upload.progress == 0) { + return const SizedBox.shrink(); + } + + return SizedBox( + width: 64, + height: 64, + child: CircularProgressIndicator( + value: upload.progress, + strokeWidth: 4, + backgroundColor: AppTheme.egyptianBlue.withOpacity(0.1), + valueColor: AlwaysStoppedAnimation( + upload.progress >= 0.99 ? Colors.green : AppTheme.brightNavy + ), + ), + ); + }, + ), + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: AppTheme.navyBlue, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: AppTheme.navyBlue.withOpacity(0.4), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], ), - ], - ), - child: const Icon( - Icons.add, - color: Colors.white, - size: 32, - ), + child: const Icon( + Icons.add, + color: Colors.white, + size: 32, + ), + ), + ], ), ), ), @@ -169,10 +200,13 @@ class _HomeShellState extends State with WidgetsBindingObserver { PreferredSizeWidget _buildAppBar() { return AppBar( - title: Image.asset( - 'assets/images/toplogo.png', - height: 38, - fit: BoxFit.contain, + title: InkWell( + onTap: () => widget.navigationShell.goBranch(0), + child: Image.asset( + 'assets/images/toplogo.png', + height: 38, + fit: BoxFit.contain, + ), ), centerTitle: false, elevation: 0, @@ -186,11 +220,9 @@ class _HomeShellState extends State with WidgetsBindingObserver { actions: [ IconButton( icon: Icon(Icons.search, color: AppTheme.navyBlue), - tooltip: 'Search', + tooltip: 'Discover', onPressed: () { - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const SearchScreen()), - ); + widget.navigationShell.goBranch(1); }, ), IconButton( diff --git a/sojorn_app/lib/screens/profile/blocked_users_screen.dart b/sojorn_app/lib/screens/profile/blocked_users_screen.dart new file mode 100644 index 0000000..eebcbad --- /dev/null +++ b/sojorn_app/lib/screens/profile/blocked_users_screen.dart @@ -0,0 +1,216 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:file_picker/file_picker.dart'; +import '../../models/profile.dart'; +import '../../providers/api_provider.dart'; +import '../../theme/app_theme.dart'; +import '../../widgets/app_scaffold.dart'; + +class BlockedUsersScreen extends ConsumerStatefulWidget { + const BlockedUsersScreen({super.key}); + + @override + ConsumerState createState() => _BlockedUsersScreenState(); +} + +class _BlockedUsersScreenState extends ConsumerState { + bool _isLoading = true; + List _blockedUsers = []; + String? _error; + + @override + void initState() { + super.initState(); + _loadBlockedUsers(); + } + + Future _loadBlockedUsers() async { + setState(() { + _isLoading = true; + _error = null; + }); + try { + final apiService = ref.read(apiServiceProvider); + final response = await apiService.callGoApi('/users/blocked', method: 'GET'); + final List usersJson = response['users'] ?? []; + + if (mounted) { + setState(() { + _blockedUsers = usersJson.map((j) => Profile.fromJson(j)).toList(); + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + } + + Future _unblockUser(Profile user) async { + try { + final apiService = ref.read(apiServiceProvider); + await apiService.callGoApi('/users/${user.id}/block', method: 'DELETE'); + + if (mounted) { + setState(() { + _blockedUsers.removeWhere((u) => u.id == user.id); + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${user.handle} unblocked')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to unblock: $e')), + ); + } + } + } + + Future _exportBlockList() async { + try { + final List handles = _blockedUsers.map((u) => u.handle ?? '').where((h) => h.isNotEmpty).toList(); + final String jsonStr = jsonEncode({ + 'version': 1, + 'type': 'sojorn_block_list', + 'exported_at': DateTime.now().toIso8601String(), + 'handles': handles, + }); + + final directory = await getTemporaryDirectory(); + final file = File('${directory.path}/sojorn_blocklist.json'); + await file.writeAsString(jsonStr); + + await Share.shareXFiles([XFile(file.path)], text: 'My Sojorn Blocklist'); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Export failed: $e')), + ); + } + } + } + + Future _importBlockList() async { + try { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['json'], + ); + + if (result == null || result.files.single.path == null) return; + + final file = File(result.files.single.path!); + final String content = await file.readAsString(); + final data = jsonDecode(content); + + if (data['type'] != 'sojorn_block_list') { + throw 'Invalid file type'; + } + + final List handles = data['handles'] ?? []; + int count = 0; + + setState(() => _isLoading = true); + + final apiService = ref.read(apiServiceProvider); + for (final handle in handles) { + try { + // Note: In a production app, we'd want a bulk block endpoint. + // For now, we block individually to reuse existing logic. + await apiService.callGoApi('/users/block_by_handle', method: 'POST', body: {'handle': handle}); + count++; + } catch (e) { + debugPrint('Failed to block $handle: $e'); + } + } + + await _loadBlockedUsers(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Import complete: Blocked $count users')), + ); + } + } catch (e) { + if (mounted) { + setState(() => _isLoading = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Import failed: $e')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return AppScaffold( + title: 'Blocked Users', + actions: [ + IconButton( + icon: const Icon(Icons.upload_file), + onPressed: _importBlockList, + tooltip: 'Import Blocklist', + ), + IconButton( + icon: const Icon(Icons.share), + onPressed: _exportBlockList, + tooltip: 'Export Blocklist', + ), + ], + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _error != null + ? Center(child: Text(_error!)) + : _blockedUsers.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.block, size: 64, color: AppTheme.egyptianBlue.withOpacity(0.3)), + const SizedBox(height: 16), + Text( + 'No blocked users', + style: AppTheme.textTheme.titleMedium?.copyWith( + color: AppTheme.navyText.withOpacity(0.5), + ), + ), + ], + ), + ) + : ListView.separated( + padding: const EdgeInsets.all(AppTheme.spacingMd), + itemCount: _blockedUsers.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final user = _blockedUsers[index]; + return ListTile( + leading: CircleAvatar( + backgroundColor: AppTheme.queenPink, + backgroundImage: user.avatarUrl != null + ? NetworkImage(user.avatarUrl!) + : null, + child: user.avatarUrl == null + ? Text(user.displayName?[0].toUpperCase() ?? '?') + : null, + ), + title: Text(user.displayName ?? user.handle ?? 'User'), + subtitle: Text('@${user.handle}'), + trailing: OutlinedButton( + onPressed: () => _unblockUser(user), + child: const Text('Unblock'), + ), + ); + }, + ), + ); + } +} diff --git a/sojorn_app/lib/screens/profile/category_settings_screen.dart b/sojorn_app/lib/screens/profile/category_settings_screen.dart new file mode 100644 index 0000000..ba8b04b --- /dev/null +++ b/sojorn_app/lib/screens/profile/category_settings_screen.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../models/category.dart'; +import '../../providers/api_provider.dart'; +import '../../theme/app_theme.dart'; +import '../../widgets/app_scaffold.dart'; + +class CategoryDiscoveryScreen extends ConsumerStatefulWidget { + const CategoryDiscoveryScreen({super.key}); + + @override + ConsumerState createState() => _CategoryDiscoveryScreenState(); +} + +class _CategoryDiscoveryScreenState extends ConsumerState { + bool _isLoading = true; + List _categories = []; + Map _settings = {}; + + @override + void initState() { + super.initState(); + _loadData(); + } + + Future _loadData() async { + setState(() => _isLoading = true); + try { + final apiService = ref.read(apiServiceProvider); + final catsResponse = await apiService.callGoApi('/categories', method: 'GET'); + final settingsResponse = await apiService.callGoApi('/categories/settings', method: 'GET'); + + final List catsJson = catsResponse['categories'] ?? []; + final List settingsJson = settingsResponse['settings'] ?? []; + + final cats = catsJson.map((j) => Category.fromJson(j)).toList(); + final settingsMap = { for (var s in settingsJson) s['category_id'] as String : s['enabled'] as bool }; + + if (mounted) { + setState(() { + _categories = cats; + // Default to !defaultOff if no setting exists + _settings = { for (var c in cats) c.id : settingsMap[c.id] ?? !c.defaultOff }; + _isLoading = false; + }); + } + } catch (e) { + if (mounted) setState(() => _isLoading = false); + } + } + + Future _toggleCategory(String catId, bool enabled) async { + final oldVal = _settings[catId]; + setState(() => _settings[catId] = enabled); + + try { + final apiService = ref.read(apiServiceProvider); + await apiService.callGoApi('/categories/settings', method: 'POST', body: { + 'category_id': catId, + 'enabled': enabled, + }); + } catch (e) { + setState(() => _settings[catId] = oldVal ?? false); + } + } + + @override + Widget build(BuildContext context) { + return AppScaffold( + title: 'Discovery Cohorts', + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : ListView.builder( + padding: const EdgeInsets.all(AppTheme.spacingLg), + itemCount: _categories.length, + itemBuilder: (context, index) { + final cat = _categories[index]; + return Container( + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppTheme.egyptianBlue.withOpacity(0.1)), + ), + child: SwitchListTile( + activeColor: AppTheme.brightNavy, + title: Text(cat.name, style: AppTheme.textTheme.labelLarge), + subtitle: Text(cat.description, style: AppTheme.textTheme.bodySmall), + value: _settings[cat.id] ?? false, + onChanged: (v) => _toggleCategory(cat.id, v), + ), + ); + }, + ), + ); + } +} diff --git a/sojorn_app/lib/screens/profile/profile_settings_screen.dart b/sojorn_app/lib/screens/profile/profile_settings_screen.dart index 653e9cc..bcba656 100644 --- a/sojorn_app/lib/screens/profile/profile_settings_screen.dart +++ b/sojorn_app/lib/screens/profile/profile_settings_screen.dart @@ -1,657 +1,621 @@ -import 'dart:async'; import 'dart:io'; +import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:image_picker/image_picker.dart'; import '../../models/profile.dart'; import '../../models/profile_privacy_settings.dart'; -import '../../models/user_settings.dart'; import '../../providers/api_provider.dart'; import '../../providers/auth_provider.dart'; +import '../../providers/settings_provider.dart'; import '../../providers/theme_provider.dart' as app_theme; import '../../services/image_upload_service.dart'; +import '../../services/notification_service.dart'; import '../../theme/app_theme.dart'; import '../../widgets/app_scaffold.dart'; import '../../widgets/media/signed_media_image.dart'; import '../../widgets/sojorn_input.dart'; import '../../widgets/sojorn_text_area.dart'; import 'follow_requests_screen.dart'; -import '../admin/quip_repair_screen.dart'; +import 'blocked_users_screen.dart'; +import 'category_settings_screen.dart'; import '../compose/image_editor_screen.dart'; import '../../models/sojorn_media_result.dart'; +class ProfileSettingsScreen extends ConsumerStatefulWidget { + final Profile? profile; + final ProfilePrivacySettings? settings; + + const ProfileSettingsScreen({super.key, this.profile, this.settings}); + + @override + ConsumerState createState() => _ProfileSettingsScreenState(); +} + class ProfileSettingsResult { final Profile profile; final ProfilePrivacySettings settings; - const ProfileSettingsResult({required this.profile, required this.settings}); -} - -class ProfileSettingsScreen extends ConsumerStatefulWidget { - final Profile profile; - final ProfilePrivacySettings settings; - const ProfileSettingsScreen( - {super.key, required this.profile, required this.settings}); - @override - ConsumerState createState() => - _ProfileSettingsScreenState(); + ProfileSettingsResult({required this.profile, required this.settings}); } class _ProfileSettingsScreenState extends ConsumerState { - late final TextEditingController _displayNameController; - late final TextEditingController _handleController; - late final TextEditingController _bioController; - late final TextEditingController _locationController; - late final TextEditingController _websiteController; - late ProfilePrivacySettings _settings; - bool _isSaving = false; - bool _isAvatarUploading = false; - String? _errorMessage; - String? _avatarUrl; - List _selectedInterests = []; - int? _defaultPostTtl; - final ImageUploadService _imageUploadService = ImageUploadService(); final ImagePicker _imagePicker = ImagePicker(); - - final _profileVisibilityOptions = const { - 'public': 'Public', - 'followers': 'Followers only', - 'private': 'Private' - }; - final _postDurationOptions = const { - null: 'Forever', - 12: '12 Hours', - 24: '24 Hours', - 72: '3 Days', - 168: '1 Week', - }; - - @override - void initState() { - super.initState(); - _displayNameController = - TextEditingController(text: widget.profile.displayName); - _handleController = TextEditingController(text: widget.profile.handle); - _bioController = TextEditingController(text: widget.profile.bio ?? ''); - _locationController = - TextEditingController(text: widget.profile.location ?? ''); - _websiteController = - TextEditingController(text: widget.profile.website ?? ''); - _selectedInterests = widget.profile.interests ?? []; - _settings = widget.settings; - _avatarUrl = widget.profile.avatarUrl; - _loadUserSettings(); - } - - Future _loadUserSettings() async { - try { - final apiService = ref.read(apiServiceProvider); - final settings = await apiService.getUserSettings(); - if (!mounted) return; - final ttl = _postDurationOptions.containsKey(settings.defaultPostTtl) - ? settings.defaultPostTtl - : null; - setState(() => _defaultPostTtl = ttl); - } catch (_) { - // Non-blocking: default to "Forever" if settings are unavailable. - } - } - - @override - void dispose() { - _displayNameController.dispose(); - _handleController.dispose(); - _bioController.dispose(); - _locationController.dispose(); - _websiteController.dispose(); - super.dispose(); - } - - Future _save() async { - if (_isSaving) return; - final displayName = _displayNameController.text.trim(); - final handle = _handleController.text.trim(); - if (displayName.isEmpty || handle.isEmpty) { - setState( - () => _errorMessage = 'Display name and username cannot be empty.'); - return; - } - setState(() { - _isSaving = true; - _errorMessage = null; - }); - try { - final apiService = ref.read(apiServiceProvider); - Profile updatedProfile = widget.profile; - final shouldUpdateProfile = handle != widget.profile.handle || - displayName != widget.profile.displayName || - _bioController.text.trim() != (widget.profile.bio ?? '') || - _locationController.text.trim() != (widget.profile.location ?? '') || - _websiteController.text.trim() != (widget.profile.website ?? '') || - _avatarUrl != widget.profile.avatarUrl; - if (shouldUpdateProfile) { - updatedProfile = await apiService.updateProfile( - handle: handle != widget.profile.handle ? handle : null, - displayName: displayName, - bio: _bioController.text.isEmpty ? null : _bioController.text.trim(), - location: _locationController.text.isEmpty - ? null - : _locationController.text.trim(), - website: _websiteController.text.isEmpty - ? null - : _websiteController.text.trim(), - interests: _selectedInterests.isEmpty ? null : _selectedInterests, - avatarUrl: _avatarUrl != widget.profile.avatarUrl ? _avatarUrl : null, - ); - } - final updatedSettings = await apiService.updatePrivacySettings(_settings); - await apiService.updateUserSettings(UserSettings( - userId: widget.profile.id, - defaultPostTtl: _defaultPostTtl, - )); - if (!mounted) return; - Navigator.of(context).pop(ProfileSettingsResult( - profile: updatedProfile, settings: updatedSettings)); - } catch (e) { - if (!mounted) return; - setState( - () => _errorMessage = e.toString().replaceAll('Exception: ', '')); - } finally { - if (mounted) { - setState(() => _isSaving = false); - } - } - } - - Future _signOut() async { - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Log out'), - content: const Text('Are you sure you want to log out?'), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(true), - child: const Text('Log out'), - ), - ], - ), - ); - - if (confirmed != true) return; - - try { - final authService = ref.read(authServiceProvider); - await authService.signOut(); - if (!mounted) return; - Navigator.of(context).popUntil((route) => route.isFirst); - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to log out: $e')), - ); - } - } + final ImageUploadService _imageUploadService = ImageUploadService(); + bool _isAvatarUploading = false; + bool _isBannerUploading = false; @override Widget build(BuildContext context) { + final state = ref.watch(settingsProvider); + final profile = state.profile; + + if (state.isLoading && profile == null) { + return const AppScaffold( + title: 'Settings', + body: Center(child: CircularProgressIndicator()), + ); + } + + if (profile == null) { + return const AppScaffold( + title: 'Settings', + body: Center(child: Text('Failed to load profile')), + ); + } + return AppScaffold( - title: 'Profile settings', - actions: [ - TextButton( - onPressed: _isSaving ? null : _save, - child: _isSaving - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator(strokeWidth: 2)) - : const Text('Save')) - ], + title: 'Sanctuary Settings', body: SingleChildScrollView( - padding: const EdgeInsets.symmetric( - horizontal: AppTheme.spacingLg, vertical: AppTheme.spacingLg), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (_errorMessage != null) ...[ - Container( - padding: const EdgeInsets.all(AppTheme.spacingMd), - decoration: BoxDecoration( - color: AppTheme.error.withOpacity(0.08), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: AppTheme.error, width: 0.5)), - child: Text(_errorMessage!, - style: AppTheme.textTheme.labelSmall - ?.copyWith(color: AppTheme.error))), - const SizedBox(height: AppTheme.spacingLg), - ], - Text('Profile', style: AppTheme.headlineSmall), - const SizedBox(height: AppTheme.spacingMd), - _buildAvatarPicker(), - const SizedBox(height: AppTheme.spacingLg), - sojornInput( - label: 'Display name', - controller: _displayNameController, - hint: 'Your name'), - const SizedBox(height: AppTheme.spacingLg), - sojornInput( - label: 'Username', - controller: _handleController, - hint: 'username', - prefix: const Text('@')), - const SizedBox(height: AppTheme.spacingLg), - sojornTextArea( - label: 'Bio', - controller: _bioController, - hint: 'A short, vibrant introduction', - maxLength: 300, - minLines: 3, - maxLines: 6), - const SizedBox(height: AppTheme.spacingLg), - sojornInput( - label: 'Website', - controller: _websiteController, - hint: 'Your website or social link'), - const SizedBox(height: AppTheme.spacingLg * 1.5), - Text('Privacy', style: AppTheme.headlineSmall), - const SizedBox(height: AppTheme.spacingMd), - if (widget.profile.isPrivate) ...[ - _buildSettingsTile( - icon: Icons.person_add_alt_1, - title: 'Follow Requests', - description: 'Review and approve pending follow requests', - color: AppTheme.royalPurple, - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const FollowRequestsScreen(), - ), - ); - }, + physics: const BouncingScrollPhysics(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildProfileHeader(profile), + Padding( + padding: const EdgeInsets.all(AppTheme.spacingLg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHarmonyInsight(state), + const SizedBox(height: AppTheme.spacingLg), + + _buildSection( + title: 'Account Sovereignty', + subtitle: 'Your data, your sanctuary', + children: [ + _buildEditTile( + icon: Icons.person_outline, + title: 'Identity', + onTap: () => _showIdentityEditor(profile), + ), + _buildEditTile( + icon: Icons.shield_outlined, + title: 'Discovery Cohorts', + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const CategoryDiscoveryScreen()), + ), + ), + _buildEditTile( + icon: Icons.pause_circle_outline, + title: 'Deactivation', + color: Colors.orange, + onTap: () {}, + ), + ], + ), + + const SizedBox(height: AppTheme.spacingLg), + _buildSection( + title: 'The Circle', + subtitle: 'Social harmony & visibility', + children: [ + _buildEditTile( + icon: Icons.person_add_alt_1_outlined, + title: 'Follow Requests', + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const FollowRequestsScreen()), + ), + ), + _buildEditTile( + icon: Icons.block_flipped, + title: 'Blocked', + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const BlockedUsersScreen()), + ), + ), + _buildEditTile( + icon: Icons.visibility_outlined, + title: 'Privacy Gates', + onTap: () => _showPrivacyEditor(), + ), + ], + ), + + const SizedBox(height: AppTheme.spacingLg), + _buildSection( + title: 'Focus Gates', + subtitle: 'Notification & Aesthetic control', + children: [ + _buildEditTile( + icon: Icons.notifications_none_outlined, + title: 'Notification Gates', + onTap: () => _showNotificationEditor(), + ), + _buildEditTile( + icon: Icons.timer_outlined, + title: 'Post Lifespan', + onTap: () => _showTtlEditor(), + ), + _buildEditTile( + icon: Icons.palette_outlined, + title: 'Vibe & Aesthetic', + onTap: () => _showAestheticEditor(), + ), + ], + ), + + const SizedBox(height: AppTheme.spacingLg * 2), + _buildLogoutButton(), + + const SizedBox(height: AppTheme.spacingLg), + _buildFooter(), + ], + ), ), - const SizedBox(height: AppTheme.spacingLg), ], - _buildDropdown( - label: 'Profile visibility', - value: _settings.profileVisibility, - options: _profileVisibilityOptions, - onChanged: (v) => setState( - () => _settings = _settings.copyWith(profileVisibility: v))), - const SizedBox(height: AppTheme.spacingLg), - _buildDropdown( - label: 'Posts visibility', - value: _settings.postsVisibility, - options: _profileVisibilityOptions, - onChanged: (v) => setState( - () => _settings = _settings.copyWith(postsVisibility: v))), - const SizedBox(height: AppTheme.spacingLg * 1.5), - Text('Posting', style: AppTheme.headlineSmall), - const SizedBox(height: AppTheme.spacingMd), - _buildTtlDropdown( - label: 'Default post duration', - value: _defaultPostTtl, - options: _postDurationOptions, - onChanged: (value) => setState(() => _defaultPostTtl = value), + ), + ), + ); + } + + Widget _buildProfileHeader(Profile profile) { + return Stack( + children: [ + // Banner + GestureDetector( + onTap: () => _pickMedia(isBanner: true), + child: Container( + height: 180, + width: double.infinity, + decoration: BoxDecoration( + color: AppTheme.egyptianBlue.withOpacity(0.1), + ), + child: _isBannerUploading + ? const Center(child: CircularProgressIndicator()) + : profile.coverUrl != null + ? SignedMediaImage(url: profile.coverUrl!, fit: BoxFit.cover) + : Center(child: Icon(Icons.add_photo_alternate_outlined, color: AppTheme.egyptianBlue.withOpacity(0.5), size: 40)), ), - const SizedBox(height: AppTheme.spacingLg * 1.5), - Text('Appearance', style: AppTheme.headlineSmall), - const SizedBox(height: AppTheme.spacingMd), - _buildThemeSwitcher(), - const SizedBox(height: AppTheme.spacingLg * 1.5), - Text('Account', style: AppTheme.headlineSmall), - const SizedBox(height: AppTheme.spacingMd), - _buildSettingsTile( - icon: Icons.pause_circle_outline, - title: 'Deactivate Account', - description: 'Temporarily hide your account', - color: Colors.orange, - onTap: () {}), - const SizedBox(height: AppTheme.spacingMd), - _buildSettingsTile( - icon: Icons.delete_forever, - title: 'Delete Account', - description: 'Permanently delete after 30 days', - color: AppTheme.error, - onTap: () {}), - const SizedBox(height: AppTheme.spacingMd), - _buildSettingsTile( - icon: Icons.logout, - title: 'Log out', - description: 'Sign out of your account', - color: AppTheme.navyBlue, - onTap: _signOut), - const SizedBox(height: AppTheme.spacingLg), - - // Debug / Admin - _buildSettingsTile( - icon: Icons.build_circle, - title: 'Repair Media', - description: 'Fix missing thumbnails', - color: Colors.grey, - onTap: () { - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const QuipRepairScreen()), - ); - }, + ), + // Glass Overlay for Banner Action + Positioned( + bottom: 12, + right: 12, + child: _buildGlassButton( + onTap: () => _pickMedia(isBanner: true), + icon: Icons.camera_alt_outlined, ), - Align( - alignment: Alignment.centerRight, - child: TextButton( - onPressed: _isSaving ? null : _save, - child: _isSaving - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator(strokeWidth: 2)) - : const Text('Save'))), - const SizedBox(height: AppTheme.spacingLg * 2), // Fixed spacing2xl error - Center( - child: Column( + ), + // Avatar + Positioned( + top: 130, + left: 24, + child: GestureDetector( + onTap: () => _pickMedia(isBanner: false), + child: Stack( children: [ - Text( - 'Sojorn', - style: AppTheme.textTheme.labelMedium?.copyWith( - color: AppTheme.navyText.withOpacity(0.5), - fontWeight: FontWeight.bold, + CircleAvatar( + radius: 42, + backgroundColor: AppTheme.scaffoldBg, + child: CircleAvatar( + radius: 38, + backgroundColor: AppTheme.queenPink, + child: _isAvatarUploading + ? const CircularProgressIndicator() + : profile.avatarUrl != null + ? ClipOval( + child: SignedMediaImage(url: profile.avatarUrl!, width: 76, height: 76, fit: BoxFit.cover), + ) + : Text(profile.displayName?[0].toUpperCase() ?? '?', style: AppTheme.headlineSmall), ), ), - const SizedBox(height: 4), - Text( - 'A product of MPLS LLC', - style: AppTheme.textTheme.labelSmall?.copyWith( - color: AppTheme.navyText.withOpacity(0.4), - ), - ), - Text( - 'Β© ${DateTime.now().year} All rights reserved', - style: AppTheme.textTheme.labelSmall?.copyWith( - color: AppTheme.navyText.withOpacity(0.4), + Positioned( + bottom: 0, + right: 0, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: AppTheme.brightNavy, + shape: BoxShape.circle, + border: Border.all(color: AppTheme.scaffoldBg, width: 2), + ), + child: const Icon(Icons.add_a_photo, size: 14, color: Colors.white), ), ), ], ), ), - const SizedBox(height: AppTheme.spacingLg), - ]), - ), - ); - } - - Widget _buildSettingsTile( - {required IconData icon, - required String title, - required String description, - required Color color, - required VoidCallback onTap}) { - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(8), - child: Container( - padding: const EdgeInsets.all(AppTheme.spacingMd), - decoration: BoxDecoration( - border: Border.all(color: color.withOpacity(0.3), width: 1), - borderRadius: BorderRadius.circular(8)), - child: Row(children: [ - Icon(icon, color: color), - const SizedBox(width: AppTheme.spacingMd), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: AppTheme.textTheme.titleMedium), - Text(description, - style: AppTheme.textTheme.bodySmall?.copyWith( - color: AppTheme.navyText.withOpacity(0.7))) - ])) - ]))); - } - - Widget _buildDropdown( - {required String label, - required String value, - required Map options, - required ValueChanged onChanged}) { - return DropdownButtonFormField( - value: value, - decoration: InputDecoration(labelText: label), - isExpanded: true, - items: options.entries - .map((e) => DropdownMenuItem(value: e.key, child: Text(e.value))) - .toList(), - onChanged: (v) => v == null ? null : onChanged(v)); - } - - Widget _buildTtlDropdown({ - required String label, - required int? value, - required Map options, - required ValueChanged onChanged, - }) { - return DropdownButtonFormField( - value: value, - decoration: InputDecoration(labelText: label), - isExpanded: true, - items: options.entries - .map( - (e) => DropdownMenuItem(value: e.key, child: Text(e.value))) - .toList(), - onChanged: (v) => onChanged(v), - ); - } - - Widget _buildThemeSwitcher() { - final currentTheme = ref.watch(app_theme.themeProvider); - return Container( - decoration: BoxDecoration( - border: Border.all(color: AppTheme.egyptianBlue, width: 1), - borderRadius: BorderRadius.circular(8)), - padding: const EdgeInsets.all(AppTheme.spacingMd), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Theme', style: AppTheme.textTheme.labelMedium), - const SizedBox(height: AppTheme.spacingMd), - Row(children: [ - Expanded( - child: _ThemeOption( - title: 'Basic', - description: 'Warm & welcoming', - isSelected: currentTheme == app_theme.ThemeMode.basic, - onTap: () => ref - .read(app_theme.themeProvider.notifier) - .setTheme(app_theme.ThemeMode.basic))), - const SizedBox(width: AppTheme.spacingMd), - Expanded( - child: _ThemeOption( - title: 'Pop', - description: 'Vibrant & energetic', - isSelected: currentTheme == app_theme.ThemeMode.pop, - onTap: () => ref - .read(app_theme.themeProvider.notifier) - .setTheme(app_theme.ThemeMode.pop))) - ]), - const SizedBox(height: AppTheme.spacingSm), - Text('Choose your app theme', - style: AppTheme.textTheme.labelSmall - ?.copyWith(color: AppTheme.navyText.withOpacity(0.6))) - ])); - } - - Widget _buildAvatarPicker() { - final avatarUrl = _avatarUrl; - return Row( - children: [ - CircleAvatar( - radius: 32, - backgroundColor: AppTheme.queenPink, - child: avatarUrl != null && avatarUrl.isNotEmpty - ? ClipOval( - child: SizedBox( - width: 64, - height: 64, - child: SignedMediaImage( - url: avatarUrl, - width: 64, - height: 64, - fit: BoxFit.cover, - ), - ), - ) - : Text( - _displayNameController.text.isNotEmpty - ? _displayNameController.text[0].toUpperCase() - : '?', - style: AppTheme.headlineSmall.copyWith( - color: AppTheme.royalPurple, - ), - ), - ), - const SizedBox(width: AppTheme.spacingMd), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Profile photo', style: AppTheme.textTheme.labelLarge), - const SizedBox(height: 4), - Text( - _isAvatarUploading - ? 'Uploading...' - : 'Tap to choose a new profile picture', - style: AppTheme.textTheme.bodySmall?.copyWith( - color: AppTheme.navyText.withOpacity(0.7), - ), - ), - ], - ), - ), - const SizedBox(width: AppTheme.spacingMd), - OutlinedButton( - onPressed: _isAvatarUploading ? null : _pickAvatar, - child: _isAvatarUploading - ? const SizedBox( - height: 16, - width: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Text('Change'), ), ], ); } - Future _pickAvatar() async { - try { - final XFile? pickedFile = await _imagePicker.pickImage( - source: ImageSource.gallery, - maxWidth: 2048, - maxHeight: 2048, - imageQuality: 90, - ); - if (pickedFile == null) return; + Widget _buildHarmonyInsight(SettingsState state) { + final trust = state.trust; + if (trust == null) return const SizedBox.shrink(); - // Open pro image editor - if (!mounted) return; - final result = await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => sojornImageEditor( - imagePath: pickedFile.path, - imageName: pickedFile.name, + return Container( + padding: const EdgeInsets.all(AppTheme.spacingMd), + decoration: BoxDecoration( + color: AppTheme.royalPurple.withOpacity(0.05), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppTheme.royalPurple.withOpacity(0.2)), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppTheme.royalPurple.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: Icon(Icons.spa_outlined, color: AppTheme.royalPurple), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Harmony State: ${trust.tier.displayName}', style: AppTheme.textTheme.labelMedium), + const SizedBox(height: 2), + Text( + 'Your current reach multiplier is based on your contribution to the community.', + style: AppTheme.textTheme.bodySmall?.copyWith(color: AppTheme.navyText.withOpacity(0.6)), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildSection({required String title, required String subtitle, required List children}) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: AppTheme.textTheme.headlineSmall), + Text(subtitle, style: AppTheme.textTheme.labelSmall?.copyWith(color: AppTheme.navyText.withOpacity(0.5))), + const SizedBox(height: AppTheme.spacingMd), + Container( + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.5), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: AppTheme.egyptianBlue.withOpacity(0.1)), + ), + clipBehavior: Clip.antiAlias, + child: Column(children: children), + ), + ], + ); + } + + Widget _buildEditTile({required IconData icon, required String title, required VoidCallback onTap, Color? color}) { + return ListTile( + onTap: onTap, + leading: Icon(icon, color: color ?? AppTheme.navyBlue, size: 22), + title: Text(title, style: AppTheme.textTheme.bodyLarge), + trailing: Icon(Icons.chevron_right, color: AppTheme.egyptianBlue.withOpacity(0.3)), + ); + } + + Widget _buildGlassButton({required VoidCallback onTap, required IconData icon}) { + return ClipRRect( + borderRadius: BorderRadius.circular(12), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(8), + color: Colors.white.withOpacity(0.2), + child: Icon(icon, color: Colors.white, size: 20), ), ), - ); + ), + ); + } - if (result == null) return; + Widget _buildLogoutButton() { + return SizedBox( + width: double.infinity, + child: TextButton.icon( + onPressed: _signOut, + icon: const Icon(Icons.logout), + label: const Text('Return to Silence (Logout)'), + style: TextButton.styleFrom( + foregroundColor: AppTheme.error, + padding: const EdgeInsets.symmetric(vertical: 16), + ), + ), + ); + } - setState(() => _isAvatarUploading = true); - - // Upload the edited image - String imageUrl; - if (result.bytes != null) { - imageUrl = await _imageUploadService.uploadImageBytes( - result.bytes!, - fileName: result.name, - ); - } else { - imageUrl = await _imageUploadService.uploadImage( - File(result.filePath!), - ); + Widget _buildFooter() { + return Center( + child: Column( + children: [ + Text('Sojorn Sanctuary', style: AppTheme.textTheme.labelMedium?.copyWith(color: AppTheme.navyText.withOpacity(0.4))), + Text('A product of MPLS LLC \u00a9 ${DateTime.now().year}', style: AppTheme.textTheme.labelSmall?.copyWith(color: AppTheme.navyText.withOpacity(0.3))), + ], + ), + ); + } + + // --- Handlers --- + + void _showIdentityEditor(Profile profile) { + final nameCtrl = TextEditingController(text: profile.displayName); + final bioCtrl = TextEditingController(text: profile.bio); + final webCtrl = TextEditingController(text: profile.website); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => Container( + decoration: BoxDecoration( + color: AppTheme.scaffoldBg, + borderRadius: const BorderRadius.vertical(top: Radius.circular(30)), + ), + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + left: 24, right: 24, top: 32, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Refine Identity', style: AppTheme.textTheme.headlineSmall), + const SizedBox(height: 24), + sojornInput(label: 'Display Name', controller: nameCtrl), + const SizedBox(height: 16), + sojornTextArea(label: 'Bio', controller: bioCtrl, minLines: 3), + const SizedBox(height: 16), + sojornInput(label: 'Website', controller: webCtrl), + const SizedBox(height: 32), + ElevatedButton( + onPressed: () { + ref.read(settingsProvider.notifier).updateProfile(profile.copyWith( + displayName: nameCtrl.text, + bio: bioCtrl.text, + website: webCtrl.text, + )); + Navigator.pop(context); + }, + child: const Text('Save Changes'), + ), + const SizedBox(height: 48), + ], + ), + ), + ); + } + + void _showPrivacyEditor() { + final state = ref.read(settingsProvider); + final privacy = state.privacy; + if (privacy == null) return; + + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (_) => Container( + decoration: BoxDecoration( + color: AppTheme.scaffoldBg, + borderRadius: const BorderRadius.vertical(top: Radius.circular(30)), + ), + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Privacy Gates', style: AppTheme.textTheme.headlineSmall), + const SizedBox(height: 16), + SwitchListTile( + title: const Text('Private Profile'), + subtitle: const Text('Only followers can see your posts and activity'), + value: privacy.isPrivateProfile, + onChanged: (v) => ref.read(settingsProvider.notifier).updatePrivacy(privacy.copyWith(isPrivateProfile: v)), + ), + const SizedBox(height: 32), + const Text('Default Post Visibility', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + SegmentedButton( + segments: const [ + ButtonSegment(value: 'public', label: Text('Public')), + ButtonSegment(value: 'followers', label: Text('Circle')), + ButtonSegment(value: 'private', label: Text('Self')), + ], + selected: {privacy.defaultPostVisibility}, + onSelectionChanged: (set) => ref.read(settingsProvider.notifier).updatePrivacy(privacy.copyWith(defaultPostVisibility: set.first)), + ), + const SizedBox(height: 48), + ], + ), + ), + ); + } + + void _showNotificationEditor() { + final state = ref.read(settingsProvider); + final userSettings = state.user; + if (userSettings == null) return; + + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (_) => Container( + decoration: BoxDecoration( + color: AppTheme.scaffoldBg, + borderRadius: const BorderRadius.vertical(top: Radius.circular(30)), + ), + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Notification Gates', style: AppTheme.textTheme.headlineSmall), + const SizedBox(height: 16), + SwitchListTile( + title: const Text('All Notifications'), + value: userSettings.notificationsEnabled, + onChanged: (v) => ref.read(settingsProvider.notifier).updateUser(userSettings.copyWith(notificationsEnabled: v)), + ), + SwitchListTile( + title: const Text('Pause Mode (Equanimity)'), + subtitle: const Text('Mute all alerts for deep focus'), + value: !userSettings.pushNotifications, + onChanged: (v) => ref.read(settingsProvider.notifier).updateUser(userSettings.copyWith(pushNotifications: !v)), + ), + const SizedBox(height: 48), + ], + ), + ), + ); + } + + void _showTtlEditor() { + final state = ref.read(settingsProvider); + final userSettings = state.user; + if (userSettings == null) return; + + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (_) => Container( + decoration: BoxDecoration( + color: AppTheme.scaffoldBg, + borderRadius: const BorderRadius.vertical(top: Radius.circular(30)), + ), + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Post Lifespan', style: AppTheme.textTheme.headlineSmall), + const SizedBox(height: 8), + Text('Impermanence is the nature of all things.', style: AppTheme.textTheme.labelSmall), + const SizedBox(height: 24), + Wrap( + spacing: 8, + children: [null, 12, 24, 72, 168].map((hours) => ChoiceChip( + label: Text(hours == null ? 'Eternal' : '$hours hrs'), + selected: userSettings.defaultPostTtl == hours, + onSelected: (s) => ref.read(settingsProvider.notifier).updateUser(userSettings.copyWith(defaultPostTtl: hours)), + )).toList(), + ), + const SizedBox(height: 48), + ], + ), + ), + ); + } + + void _showAestheticEditor() { + final currentTheme = ref.read(app_theme.themeProvider); + + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (_) => Container( + decoration: BoxDecoration( + color: AppTheme.scaffoldBg, + borderRadius: const BorderRadius.vertical(top: Radius.circular(30)), + ), + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Vibe & Aesthetic', style: AppTheme.headlineSmall), + const SizedBox(height: 24), + ListTile( + title: const Text('Basic (Serene)'), + subtitle: const Text('A calm, standard sanctuary experience'), + leading: Radio( + value: app_theme.ThemeMode.basic, + groupValue: currentTheme, + onChanged: (v) { + ref.read(app_theme.themeProvider.notifier).setTheme(v!); + Navigator.pop(context); + }, + ), + onTap: () { + ref.read(app_theme.themeProvider.notifier).setTheme(app_theme.ThemeMode.basic); + Navigator.pop(context); + }, + ), + ListTile( + title: const Text('Pop (Vibrant)'), + subtitle: const Text('High energy, dynamic colors'), + leading: Radio( + value: app_theme.ThemeMode.pop, + groupValue: currentTheme, + onChanged: (v) { + ref.read(app_theme.themeProvider.notifier).setTheme(v!); + Navigator.pop(context); + }, + ), + onTap: () { + ref.read(app_theme.themeProvider.notifier).setTheme(app_theme.ThemeMode.pop); + Navigator.pop(context); + }, + ), + const SizedBox(height: 48), + ], + ), + ), + ); + } + + Future _pickMedia({required bool isBanner}) async { + final XFile? file = await _imagePicker.pickImage(source: ImageSource.gallery); + if (file == null) return; + + if (!mounted) return; + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => sojornImageEditor(imagePath: file.path, imageName: file.name), + ), + ); + + if (result == null) return; + + setState(() => isBanner ? _isBannerUploading = true : _isAvatarUploading = true); + + try { + final url = await _imageUploadService.uploadImage(File(result.filePath!)); + final profile = ref.read(settingsProvider).profile; + if (profile != null) { + if (isBanner) { + await ref.read(settingsProvider.notifier).updateProfile(profile.copyWith(coverUrl: url)); + } else { + await ref.read(settingsProvider.notifier).updateProfile(profile.copyWith(avatarUrl: url)); + } } - - if (!mounted) return; - setState(() { - _avatarUrl = imageUrl; - }); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Profile photo updated. Tap Save to apply.')), - ); } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to upload profile photo: $e')), - ); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Upload failed: $e'))); + } } finally { if (mounted) { - setState(() => _isAvatarUploading = false); + setState(() => isBanner ? _isBannerUploading = false : _isAvatarUploading = false); } } } -} -class _ThemeOption extends StatelessWidget { - final String title, description; - final bool isSelected; - final VoidCallback onTap; - const _ThemeOption( - {required this.title, - required this.description, - required this.isSelected, - required this.onTap}); - @override - Widget build(BuildContext context) { - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(8), - child: Container( - padding: const EdgeInsets.all(AppTheme.spacingMd), - decoration: BoxDecoration( - color: isSelected - ? AppTheme.royalPurple.withOpacity(0.1) - : Colors.transparent, - border: Border.all( - color: isSelected - ? AppTheme.royalPurple - : AppTheme.egyptianBlue.withOpacity(0.3), - width: isSelected ? 2 : 1), - borderRadius: BorderRadius.circular(8)), - child: - Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - Icon( - isSelected - ? Icons.radio_button_checked - : Icons.radio_button_unchecked, - color: isSelected - ? AppTheme.royalPurple - : AppTheme.egyptianBlue, - size: 20), - const SizedBox(width: 8), - Expanded( - child: Text(title, - style: AppTheme.textTheme.labelLarge?.copyWith( - color: isSelected - ? AppTheme.royalPurple - : AppTheme.navyText, - fontWeight: isSelected - ? FontWeight.w700 - : FontWeight.w600))) - ]), - const SizedBox(height: 4), - Padding( - padding: const EdgeInsets.only(left: 28), - child: Text(description, - style: AppTheme.textTheme.labelSmall?.copyWith( - color: AppTheme.navyText.withOpacity(0.7)))) - ]))); + Future _signOut() async { + final authService = ref.read(authServiceProvider); + await NotificationService.instance.removeToken(); + await authService.signOut(); + if (!mounted) return; + Navigator.of(context).popUntil((route) => route.isFirst); } } diff --git a/sojorn_app/lib/screens/search/search_screen.dart b/sojorn_app/lib/screens/search/search_screen.dart index b79c1fe..1f1f8e9 100644 --- a/sojorn_app/lib/screens/search/search_screen.dart +++ b/sojorn_app/lib/screens/search/search_screen.dart @@ -31,14 +31,19 @@ class _SearchScreenState extends ConsumerState { SearchResults? results; List recentSearches = []; int _searchEpoch = 0; + final Map> _postFutures = {}; + + // Discovery State + bool _isDiscoveryLoading = false; + List _discoveryPosts = []; static const Duration debounceDuration = Duration(milliseconds: 250); static const List trendingTags = [ 'safety', 'wellness', 'growth', - 'mindfulness', + 'focus', 'community', 'insights', 'reflection', @@ -62,6 +67,25 @@ class _SearchScreenState extends ConsumerState { focusNode.requestFocus(); }); } + + _loadDiscoveryContent(); + } + + Future _loadDiscoveryContent() async { + setState(() => _isDiscoveryLoading = true); + try { + final apiService = ref.read(apiServiceProvider); + // Fetch feed content to use as "Popular Now" / Discovery + final posts = await apiService.getSojornFeed(limit: 10); + if (mounted) { + setState(() { + _discoveryPosts = posts; + _isDiscoveryLoading = false; + }); + } + } catch (e) { + if (mounted) setState(() => _isDiscoveryLoading = false); + } } @override @@ -300,7 +324,7 @@ class _SearchScreenState extends ConsumerState { if (isLoading) return buildLoadingState(); if (hasSearched && results != null) return buildResultsState(); if (recentSearches.isNotEmpty) return buildRecentSearchesState(); - return buildTrendingState(); + return buildDiscoveryState(); } Widget buildLoadingState() { @@ -380,6 +404,20 @@ class _SearchScreenState extends ConsumerState { ), const SizedBox(height: 24), ], + if (results!.posts.isNotEmpty) ...[ + buildSectionHeader('Posts'), + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: results!.posts.length, + itemBuilder: (context, index) { + final post = results!.posts[index]; + return buildPostResultItem(post); + }, + ), + const SizedBox(height: 24), + ], if (results!.tags.isNotEmpty) ...[ buildSectionHeader(isTagSearch ? 'Tag' : 'Tags'), ListView.builder( @@ -407,19 +445,6 @@ class _SearchScreenState extends ConsumerState { ), const SizedBox(height: 24), ], - if (results!.posts.isNotEmpty) ...[ - buildSectionHeader('Posts'), - ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - padding: const EdgeInsets.symmetric(horizontal: 16), - itemCount: results!.posts.length, - itemBuilder: (context, index) { - final post = results!.posts[index]; - return buildPostResultItem(post); - }, - ), - ], ], ), ); @@ -475,17 +500,20 @@ class _SearchScreenState extends ConsumerState { ); } - Widget buildTrendingState() { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text('Trending Tags', - style: AppTheme.labelMedium.copyWith(color: AppTheme.navyBlue)), - ), - Expanded( - child: GridView.builder( + Widget buildDiscoveryState() { + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text('Top Trending', + style: AppTheme.labelMedium.copyWith(color: AppTheme.navyBlue)), + ), + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), padding: const EdgeInsets.symmetric(horizontal: 16), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, @@ -506,8 +534,43 @@ class _SearchScreenState extends ConsumerState { ); }, ), - ), - ], + + if (_isDiscoveryLoading) ...[ + const SizedBox(height: 32), + const Center(child: CircularProgressIndicator()), + ] else if (_discoveryPosts.isNotEmpty) ...[ + const SizedBox(height: 32), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Text('Popular Now', + style: AppTheme.labelMedium.copyWith(color: AppTheme.navyBlue)), + ), + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: _discoveryPosts.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final post = _discoveryPosts[index]; + return Container( + decoration: BoxDecoration( + color: AppTheme.cardSurface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppTheme.egyptianBlue.withOpacity(0.2)), + ), + child: sojornPostCard( + post: post, + onTap: () => _openPostDetail(post), + onChain: () => _openChainComposer(post), + ), + ); + }, + ), + const SizedBox(height: 32), + ], + ], + ), ); } diff --git a/sojorn_app/lib/services/api_service.dart b/sojorn_app/lib/services/api_service.dart index 4c6a14a..b297484 100644 --- a/sojorn_app/lib/services/api_service.dart +++ b/sojorn_app/lib/services/api_service.dart @@ -170,6 +170,12 @@ class ApiService { return []; } + /// Simple GET request helper + Future> get(String path, {Map? queryParams}) async { + return _callGoApi(path, method: 'GET', queryParams: queryParams); + } + + Future resendVerificationEmail(String email) async { await _callGoApi('/auth/resend-verification', method: 'POST', body: {'email': email}); @@ -714,27 +720,6 @@ class ApiService { } } - // Beacon voting - migrated to Go API - Future vouchBeacon(String beaconId) async { - await _callGoApi( - '/beacons/$beaconId/vouch', - method: 'POST', - ); - } - - Future reportBeacon(String beaconId) async { - await _callGoApi( - '/beacons/$beaconId/report', - method: 'POST', - ); - } - - Future removeBeaconVote(String beaconId) async { - await _callGoApi( - '/beacons/$beaconId/vouch', - method: 'DELETE', - ); - } // ========================================================================= // Social Actions @@ -1030,7 +1015,7 @@ class ApiService { return posts.map((p) => Post.fromJson(p)).toList(); } - Future> getsojornFeed({int limit = 20, int offset = 0}) async { + Future> getSojornFeed({int limit = 20, int offset = 0}) async { return getPersonalFeed(limit: limit, offset: offset); } @@ -1095,6 +1080,31 @@ class ApiService { ); } + // ========================================================================= + // Beacon Actions + // ========================================================================= + + Future vouchBeacon(String beaconId) async { + await callGoApi( + '/beacons/$beaconId/vouch', + method: 'POST', + ); + } + + Future reportBeacon(String beaconId) async { + await callGoApi( + '/beacons/$beaconId/report', + method: 'POST', + ); + } + + Future removeBeaconVote(String beaconId) async { + await callGoApi( + '/beacons/$beaconId/vouch', + method: 'DELETE', + ); + } + // ========================================================================= // Key Backup & Recovery // ========================================================================= diff --git a/sojorn_app/lib/services/notification_service.dart b/sojorn_app/lib/services/notification_service.dart index eb8a8dd..61da650 100644 --- a/sojorn_app/lib/services/notification_service.dart +++ b/sojorn_app/lib/services/notification_service.dart @@ -1,6 +1,11 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:permission_handler/permission_handler.dart'; import '../config/firebase_web_config.dart'; import '../routes/app_routes.dart'; @@ -8,6 +13,144 @@ import '../services/secure_chat_service.dart'; import '../screens/secure_chat/secure_chat_screen.dart'; import 'api_service.dart'; +/// NotificationPreferences model +class NotificationPreferences { + final bool pushEnabled; + final bool pushLikes; + final bool pushComments; + final bool pushReplies; + final bool pushMentions; + final bool pushFollows; + final bool pushFollowRequests; + final bool pushMessages; + final bool pushSaves; + final bool pushBeacons; + final bool emailEnabled; + final String emailDigestFrequency; + final bool quietHoursEnabled; + final String? quietHoursStart; + final String? quietHoursEnd; + final bool showBadgeCount; + + NotificationPreferences({ + this.pushEnabled = true, + this.pushLikes = true, + this.pushComments = true, + this.pushReplies = true, + this.pushMentions = true, + this.pushFollows = true, + this.pushFollowRequests = true, + this.pushMessages = true, + this.pushSaves = true, + this.pushBeacons = true, + this.emailEnabled = false, + this.emailDigestFrequency = 'never', + this.quietHoursEnabled = false, + this.quietHoursStart, + this.quietHoursEnd, + this.showBadgeCount = true, + }); + + factory NotificationPreferences.fromJson(Map json) { + return NotificationPreferences( + pushEnabled: json['push_enabled'] ?? true, + pushLikes: json['push_likes'] ?? true, + pushComments: json['push_comments'] ?? true, + pushReplies: json['push_replies'] ?? true, + pushMentions: json['push_mentions'] ?? true, + pushFollows: json['push_follows'] ?? true, + pushFollowRequests: json['push_follow_requests'] ?? true, + pushMessages: json['push_messages'] ?? true, + pushSaves: json['push_saves'] ?? true, + pushBeacons: json['push_beacons'] ?? true, + emailEnabled: json['email_enabled'] ?? false, + emailDigestFrequency: json['email_digest_frequency'] ?? 'never', + quietHoursEnabled: json['quiet_hours_enabled'] ?? false, + quietHoursStart: json['quiet_hours_start'], + quietHoursEnd: json['quiet_hours_end'], + showBadgeCount: json['show_badge_count'] ?? true, + ); + } + + Map toJson() => { + 'push_enabled': pushEnabled, + 'push_likes': pushLikes, + 'push_comments': pushComments, + 'push_replies': pushReplies, + 'push_mentions': pushMentions, + 'push_follows': pushFollows, + 'push_follow_requests': pushFollowRequests, + 'push_messages': pushMessages, + 'push_saves': pushSaves, + 'push_beacons': pushBeacons, + 'email_enabled': emailEnabled, + 'email_digest_frequency': emailDigestFrequency, + 'quiet_hours_enabled': quietHoursEnabled, + 'quiet_hours_start': quietHoursStart, + 'quiet_hours_end': quietHoursEnd, + 'show_badge_count': showBadgeCount, + }; + + NotificationPreferences copyWith({ + bool? pushEnabled, + bool? pushLikes, + bool? pushComments, + bool? pushReplies, + bool? pushMentions, + bool? pushFollows, + bool? pushFollowRequests, + bool? pushMessages, + bool? pushSaves, + bool? pushBeacons, + bool? emailEnabled, + String? emailDigestFrequency, + bool? quietHoursEnabled, + String? quietHoursStart, + String? quietHoursEnd, + bool? showBadgeCount, + }) { + return NotificationPreferences( + pushEnabled: pushEnabled ?? this.pushEnabled, + pushLikes: pushLikes ?? this.pushLikes, + pushComments: pushComments ?? this.pushComments, + pushReplies: pushReplies ?? this.pushReplies, + pushMentions: pushMentions ?? this.pushMentions, + pushFollows: pushFollows ?? this.pushFollows, + pushFollowRequests: pushFollowRequests ?? this.pushFollowRequests, + pushMessages: pushMessages ?? this.pushMessages, + pushSaves: pushSaves ?? this.pushSaves, + pushBeacons: pushBeacons ?? this.pushBeacons, + emailEnabled: emailEnabled ?? this.emailEnabled, + emailDigestFrequency: emailDigestFrequency ?? this.emailDigestFrequency, + quietHoursEnabled: quietHoursEnabled ?? this.quietHoursEnabled, + quietHoursStart: quietHoursStart ?? this.quietHoursStart, + quietHoursEnd: quietHoursEnd ?? this.quietHoursEnd, + showBadgeCount: showBadgeCount ?? this.showBadgeCount, + ); + } +} + +/// Badge count model +class UnreadBadge { + final int notificationCount; + final int messageCount; + final int totalCount; + + UnreadBadge({ + this.notificationCount = 0, + this.messageCount = 0, + this.totalCount = 0, + }); + + factory UnreadBadge.fromJson(Map json) { + return UnreadBadge( + notificationCount: json['notification_count'] ?? 0, + messageCount: json['message_count'] ?? 0, + totalCount: json['total_count'] ?? 0, + ); + } +} + class NotificationService { NotificationService._internal(); @@ -19,6 +162,19 @@ class NotificationService { String? _currentToken; String? _cachedVapidKey; + // Badge count stream for UI updates + final StreamController _badgeController = StreamController.broadcast(); + Stream get badgeStream => _badgeController.stream; + UnreadBadge _currentBadge = UnreadBadge(); + UnreadBadge get currentBadge => _currentBadge; + + // Foreground notification stream for in-app banners + final StreamController _foregroundMessageController = StreamController.broadcast(); + Stream get foregroundMessages => _foregroundMessageController.stream; + + // Global overlay entry for in-app notification banner + OverlayEntry? _currentBannerOverlay; + Future init() async { if (_initialized) return; _initialized = true; @@ -26,6 +182,15 @@ class NotificationService { try { debugPrint('[FCM] Initializing for platform: ${_resolveDeviceType()}'); + // Android 13+ requires explicit runtime permission request + if (!kIsWeb && Platform.isAndroid) { + final permissionStatus = await _requestAndroidNotificationPermission(); + if (permissionStatus != PermissionStatus.granted) { + debugPrint('[FCM] Android notification permission not granted: $permissionStatus'); + return; + } + } + final settings = await _messaging.requestPermission( alert: true, badge: true, @@ -52,31 +217,41 @@ class NotificationService { if (token != null) { _currentToken = token; - debugPrint('[FCM] Token registered (${_resolveDeviceType()}): $token'); + debugPrint('[FCM] Token registered (${_resolveDeviceType()}): ${token.substring(0, 20)}...'); await _upsertToken(token); } else { debugPrint('[FCM] WARNING: Token is null after getToken()'); } _messaging.onTokenRefresh.listen((newToken) { - debugPrint('[FCM] Token refreshed: $newToken'); + debugPrint('[FCM] Token refreshed'); _currentToken = newToken; _upsertToken(newToken); }); + // Handle messages when app is opened from notification FirebaseMessaging.onMessageOpenedApp.listen(_handleMessageOpen); + + // Handle foreground messages - show in-app banner FirebaseMessaging.onMessage.listen((message) { - debugPrint('[FCM] Foreground message received: ${message.messageId}'); - debugPrint('[FCM] Message data: ${message.data}'); - debugPrint('[FCM] Notification: ${message.notification?.title}'); + debugPrint('[FCM] Foreground message received: ${message.notification?.title}'); + _foregroundMessageController.add(message); + _refreshBadgeCount(); }); + // Check for initial message (app opened from terminated state) final initialMessage = await _messaging.getInitialMessage(); if (initialMessage != null) { - debugPrint('[FCM] App opened from notification: ${initialMessage.messageId}'); - await _handleMessageOpen(initialMessage); + debugPrint('[FCM] App opened from notification'); + // Delay to allow navigation setup + Future.delayed(const Duration(milliseconds: 500), () { + _handleMessageOpen(initialMessage); + }); } + // Initial badge count fetch + await _refreshBadgeCount(); + debugPrint('[FCM] Initialization complete'); } catch (e, stackTrace) { debugPrint('[FCM] Failed to initialize notifications: $e'); @@ -84,24 +259,52 @@ class NotificationService { } } + /// Request POST_NOTIFICATIONS permission for Android 13+ (API 33+) + Future _requestAndroidNotificationPermission() async { + try { + final status = await Permission.notification.status; + debugPrint('[FCM] Current Android permission status: $status'); + + if (status.isDenied || status.isRestricted) { + final result = await Permission.notification.request(); + debugPrint('[FCM] Android permission request result: $result'); + return result; + } + + return status; + } catch (e) { + debugPrint('[FCM] Error requesting Android notification permission: $e'); + return PermissionStatus.granted; + } + } + /// Remove the current device's FCM token (call on logout) Future removeToken() async { - if (_currentToken == null) return; + if (_currentToken == null) { + debugPrint('[FCM] No token to revoke'); + return; + } try { - debugPrint('[FCM] Revoking token...'); + debugPrint('[FCM] Revoking token from backend...'); await ApiService.instance.callGoApi( '/notifications/device', method: 'DELETE', body: { - 'fcm_token': _currentToken, + 'token': _currentToken, }, ); - debugPrint('[FCM] Token revoked successfully'); + debugPrint('[FCM] Token revoked successfully from backend'); + + await _messaging.deleteToken(); + debugPrint('[FCM] Token deleted from Firebase'); } catch (e) { debugPrint('[FCM] Failed to revoke token: $e'); } finally { _currentToken = null; + _initialized = false; + _currentBadge = UnreadBadge(); + _badgeController.add(_currentBadge); } } @@ -125,17 +328,9 @@ class NotificationService { String _resolveDeviceType() { if (kIsWeb) return 'web'; - switch (defaultTargetPlatform) { - case TargetPlatform.iOS: - return 'ios'; - case TargetPlatform.android: - return 'android'; - case TargetPlatform.fuchsia: - case TargetPlatform.linux: - case TargetPlatform.macOS: - case TargetPlatform.windows: - return 'desktop'; - } + if (Platform.isAndroid) return 'android'; + if (Platform.isIOS) return 'ios'; + return 'desktop'; } Future _resolveVapidKey() async { @@ -151,19 +346,203 @@ class NotificationService { return null; } + // ============================================================================ + // Badge Count Management + // ============================================================================ + + Future _refreshBadgeCount() async { + try { + final response = await ApiService.instance.callGoApi( + '/notifications/badge', + method: 'GET', + ); + _currentBadge = UnreadBadge.fromJson(response); + _badgeController.add(_currentBadge); + } catch (e) { + debugPrint('[FCM] Failed to refresh badge count: $e'); + } + } + + /// Call this after marking notifications as read + Future refreshBadge() => _refreshBadgeCount(); + + // ============================================================================ + // Preferences Management + // ============================================================================ + + Future getPreferences() async { + try { + final response = await ApiService.instance.callGoApi( + '/notifications/preferences', + method: 'GET', + ); + return NotificationPreferences.fromJson(response); + } catch (e) { + debugPrint('[FCM] Failed to get preferences: $e'); + return NotificationPreferences(); + } + } + + Future updatePreferences(NotificationPreferences prefs) async { + try { + await ApiService.instance.callGoApi( + '/notifications/preferences', + method: 'PUT', + body: prefs.toJson(), + ); + return true; + } catch (e) { + debugPrint('[FCM] Failed to update preferences: $e'); + return false; + } + } + + // ============================================================================ + // Notification Actions + // ============================================================================ + + Future markAsRead(String notificationId) async { + try { + await ApiService.instance.callGoApi( + '/notifications/$notificationId/read', + method: 'PUT', + ); + await _refreshBadgeCount(); + } catch (e) { + debugPrint('[FCM] Failed to mark as read: $e'); + } + } + + Future markAllAsRead() async { + try { + await ApiService.instance.callGoApi( + '/notifications/read-all', + method: 'PUT', + ); + _currentBadge = UnreadBadge(); + _badgeController.add(_currentBadge); + } catch (e) { + debugPrint('[FCM] Failed to mark all as read: $e'); + } + } + + // ============================================================================ + // In-App Notification Banner + // ============================================================================ + + /// Show an in-app notification banner + void showNotificationBanner(BuildContext context, RemoteMessage message) { + // Dismiss any existing banner + _dismissCurrentBanner(); + + final overlay = Overlay.of(context); + + _currentBannerOverlay = OverlayEntry( + builder: (context) => _NotificationBanner( + message: message, + onDismiss: _dismissCurrentBanner, + onTap: () { + _dismissCurrentBanner(); + _handleMessageOpen(message); + }, + ), + ); + + overlay.insert(_currentBannerOverlay!); + + // Auto-dismiss after 4 seconds + Future.delayed(const Duration(seconds: 4), _dismissCurrentBanner); + } + + void _dismissCurrentBanner() { + _currentBannerOverlay?.remove(); + _currentBannerOverlay = null; + } + + // ============================================================================ + // Navigation Handling + // ============================================================================ + Future _handleMessageOpen(RemoteMessage message) async { final data = message.data; - if (data['type'] != 'chat' && data['type'] != 'new_message') return; - final conversationId = data['conversation_id']; - if (conversationId == null) return; + final type = data['type'] as String?; + + debugPrint('[FCM] Handling message open - type: $type, data: $data'); - await _openConversation(conversationId.toString()); + final navigator = AppRoutes.rootNavigatorKey.currentState; + if (navigator == null) { + debugPrint('[FCM] Navigator not available'); + return; + } + + switch (type) { + case 'chat': + case 'new_message': + case 'message': + final conversationId = data['conversation_id']; + if (conversationId != null) { + await _openConversation(conversationId.toString()); + } + break; + + case 'like': + case 'save': + case 'comment': + case 'reply': + case 'mention': + final postId = data['post_id']; + final target = data['target']; + if (postId != null) { + _navigateToPost(navigator, postId, target); + } + break; + + case 'new_follower': + case 'follow': + case 'follow_request': + case 'follow_accepted': + final followerId = data['follower_id']; + if (followerId != null) { + navigator.context.push('/u/$followerId'); + } else { + navigator.context.go(AppRoutes.profile); + } + break; + + case 'beacon_vouch': + case 'beacon_report': + navigator.context.go(AppRoutes.beaconPrefix); + break; + + default: + debugPrint('[FCM] Unknown notification type: $type'); + break; + } + } + + void _navigateToPost(NavigatorState navigator, String postId, String? target) { + switch (target) { + case 'beacon_map': + navigator.context.go(AppRoutes.beaconPrefix); + break; + case 'quip_feed': + navigator.context.go(AppRoutes.quips); + break; + case 'thread_view': + case 'main_feed': + default: + navigator.context.go(AppRoutes.home); + break; + } } Future _openConversation(String conversationId) async { final conversation = await SecureChatService.instance.getConversationById(conversationId); - if (conversation == null) return; + if (conversation == null) { + debugPrint('[FCM] Conversation not found: $conversationId'); + return; + } final navigator = AppRoutes.rootNavigatorKey.currentState; if (navigator == null) return; @@ -174,4 +553,204 @@ class NotificationService { ), ); } + + void dispose() { + _badgeController.close(); + _foregroundMessageController.close(); + } +} + +// ============================================================================ +// In-App Notification Banner Widget +// ============================================================================ + +class _NotificationBanner extends StatefulWidget { + final RemoteMessage message; + final VoidCallback onDismiss; + final VoidCallback onTap; + + const _NotificationBanner({ + required this.message, + required this.onDismiss, + required this.onTap, + }); + + @override + State<_NotificationBanner> createState() => _NotificationBannerState(); +} + +class _NotificationBannerState extends State<_NotificationBanner> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _slideAnimation; + late Animation _fadeAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + ); + _slideAnimation = Tween( + begin: const Offset(0, -1), + end: Offset.zero, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)); + _fadeAnimation = Tween(begin: 0, end: 1).animate(_controller); + _controller.forward(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + String _getNotificationIcon(String? type) { + switch (type) { + case 'like': + return '❀️'; + case 'comment': + case 'reply': + return 'πŸ’¬'; + case 'mention': + return '@'; + case 'follow': + case 'new_follower': + return 'πŸ‘€'; + case 'follow_request': + return 'πŸ””'; + case 'message': + case 'chat': + case 'new_message': + return 'βœ‰οΈ'; + case 'save': + return 'πŸ”–'; + case 'beacon_vouch': + return 'βœ…'; + case 'beacon_report': + return '⚠️'; + default: + return 'πŸ””'; + } + } + + @override + Widget build(BuildContext context) { + final notification = widget.message.notification; + final type = widget.message.data['type'] as String?; + final mediaQuery = MediaQuery.of(context); + + return Positioned( + top: mediaQuery.padding.top + 8, + left: 16, + right: 16, + child: SlideTransition( + position: _slideAnimation, + child: FadeTransition( + opacity: _fadeAnimation, + child: Material( + elevation: 8, + borderRadius: BorderRadius.circular(16), + color: Colors.transparent, + child: GestureDetector( + onTap: widget.onTap, + onHorizontalDragEnd: (details) { + if (details.primaryVelocity != null && + details.primaryVelocity!.abs() > 500) { + widget.onDismiss(); + } + }, + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Colors.grey[900]!, + Colors.grey[850]!, + ], + ), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Colors.white.withOpacity(0.1), + width: 1, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.3), + blurRadius: 20, + offset: const Offset(0, 8), + ), + ], + ), + child: Row( + children: [ + // Icon + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Center( + child: Text( + _getNotificationIcon(type), + style: const TextStyle(fontSize: 20), + ), + ), + ), + const SizedBox(width: 12), + // Content + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + notification?.title ?? 'Sojorn', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 15, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (notification?.body != null) ...[ + const SizedBox(height: 4), + Text( + notification!.body!, + style: TextStyle( + color: Colors.white.withOpacity(0.7), + fontSize: 13, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + // Dismiss button + GestureDetector( + onTap: widget.onDismiss, + child: Container( + padding: const EdgeInsets.all(8), + child: Icon( + Icons.close, + color: Colors.white.withOpacity(0.5), + size: 18, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } } diff --git a/sojorn_app/lib/theme/app_theme.dart b/sojorn_app/lib/theme/app_theme.dart index 57c2ee4..7d8caf6 100644 --- a/sojorn_app/lib/theme/app_theme.dart +++ b/sojorn_app/lib/theme/app_theme.dart @@ -102,6 +102,7 @@ class AppTheme { static TextStyle get bodyLarge => textTheme.bodyLarge!; static TextStyle get headlineMedium => textTheme.headlineMedium!; static TextStyle get headlineSmall => textTheme.headlineSmall!; + static TextStyle get labelLarge => textTheme.labelLarge!; static TextStyle get labelMedium => textTheme.labelMedium!; static TextStyle get labelSmall => textTheme.labelSmall!; diff --git a/sojorn_app/lib/widgets/modals/sanctuary_sheet.dart b/sojorn_app/lib/widgets/modals/sanctuary_sheet.dart new file mode 100644 index 0000000..8e42d28 --- /dev/null +++ b/sojorn_app/lib/widgets/modals/sanctuary_sheet.dart @@ -0,0 +1,320 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; +import '../../models/post.dart'; +import '../../theme/app_theme.dart'; +import '../../services/api_service.dart'; + +class SanctuarySheet extends StatefulWidget { + final Post post; + + const SanctuarySheet({super.key, required this.post}); + + static Future show(BuildContext context, Post post) { + return showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (_) => SanctuarySheet(post: post), + ); + } + + @override + State createState() => _SanctuarySheetState(); +} + +class _SanctuarySheetState extends State { + int _step = 0; // 0: Options, 1: Report Type, 2: Report Description, 3: Block Confirmation + String? _violationType; + final TextEditingController _descriptionController = TextEditingController(); + bool _isProcessing = false; + + @override + Widget build(BuildContext context) { + return BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: Container( + decoration: BoxDecoration( + color: AppTheme.scaffoldBg, + borderRadius: const BorderRadius.vertical(top: Radius.circular(30)), + border: Border.all( + color: AppTheme.egyptianBlue.withOpacity(0.1), + width: 1.5, + ), + ), + padding: EdgeInsets.fromLTRB(24, 12, 24, MediaQuery.of(context).viewInsets.bottom + 24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Handle + Container( + width: 40, + height: 5, + decoration: BoxDecoration( + color: AppTheme.egyptianBlue.withOpacity(0.2), + borderRadius: BorderRadius.circular(10), + ), + ), + const SizedBox(height: 24), + _buildContent(), + ], + ), + ), + ); + } + + Widget _buildContent() { + if (_isProcessing) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 40), + child: CircularProgressIndicator(), + ); + } + + switch (_step) { + case 0: + return _buildOptions(); + case 1: + return _buildReportTypes(); + case 2: + return _buildReportDescription(); + case 3: + return _buildBlockConfirmation(); + default: + return const SizedBox.shrink(); + } + } + + Widget _buildOptions() { + return Column( + children: [ + Text( + "The Sanctuary", + style: AppTheme.headlineSmall, + ), + const SizedBox(height: 8), + Text( + "Protect the harmony of your Circle.", + style: AppTheme.labelSmall.copyWith( + color: AppTheme.navyText.withOpacity(0.6), + ), + ), + const SizedBox(height: 32), + _buildActionTile( + icon: Icons.flag_outlined, + title: "Report Violation", + subtitle: "Harassment, Scam, or Misinformation detected", + onTap: () => setState(() => _step = 1), + ), + const SizedBox(height: 16), + _buildActionTile( + icon: Icons.block_flipped, + title: "Exclude User", + subtitle: "Stop all interactions structurally", + color: Colors.redAccent.withOpacity(0.8), + onTap: () => setState(() => _step = 3), + ), + ], + ); + } + + Widget _buildReportTypes() { + final types = [ + {'id': 'harassment', 'label': 'Harassment', 'desc': 'Hostility or aggression'}, + {'id': 'scam', 'label': 'Scam / Fraud', 'desc': 'Fraudulent or manipulative content'}, + {'id': 'misinformation', 'label': 'Misinformation', 'desc': 'False or harmful ignorance'}, + ]; + + return Column( + children: [ + Text( + "Natures of Violation", + style: AppTheme.headlineSmall.copyWith(fontSize: 20), + ), + const SizedBox(height: 24), + ...types.map((t) => Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _buildActionTile( + title: t['label']!, + subtitle: t['desc']!, + onTap: () { + setState(() { + _violationType = t['id']; + _step = 2; + }); + }, + ), + )), + TextButton( + onPressed: () => setState(() => _step = 0), + child: Text("Back", style: TextStyle(color: AppTheme.egyptianBlue)), + ), + ], + ); + } + + Widget _buildReportDescription() { + return Column( + children: [ + Text( + "Detail the Disturbance", + style: AppTheme.headlineSmall.copyWith(fontSize: 20), + ), + const SizedBox(height: 24), + TextField( + controller: _descriptionController, + maxLines: 4, + style: TextStyle(color: AppTheme.navyText), + decoration: InputDecoration( + hintText: "Briefly describe the violation...", + hintStyle: TextStyle(color: AppTheme.navyText.withOpacity(0.4)), + filled: true, + fillColor: AppTheme.egyptianBlue.withOpacity(0.05), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(15), + borderSide: BorderSide.none, + ), + ), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + height: 50, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.brightNavy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)), + ), + onPressed: _submitReport, + child: const Text("Submit Report", style: TextStyle(color: Colors.white)), + ), + ), + TextButton( + onPressed: () => setState(() => _step = 1), + child: Text("Back", style: TextStyle(color: AppTheme.egyptianBlue)), + ), + ], + ); + } + + Widget _buildBlockConfirmation() { + return Column( + children: [ + const Icon(Icons.warning_amber_rounded, color: Colors.redAccent, size: 64), + const SizedBox(height: 16), + Text( + "Exclude from Circle?", + style: AppTheme.headlineSmall.copyWith(fontSize: 22, color: AppTheme.error), + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Text( + "This will structurally separate you and @${widget.post.author?.handle ?? 'this user'}. You will both be invisible to each other across Sojorn.", + textAlign: TextAlign.center, + style: AppTheme.bodyMedium.copyWith(color: AppTheme.navyText.withOpacity(0.7)), + ), + ), + const SizedBox(height: 32), + SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.redAccent, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)), + ), + onPressed: _confirmBlock, + child: const Text("Yes, Exclude structurally", style: TextStyle(color: Colors.white)), + ), + ), + const SizedBox(height: 8), + TextButton( + onPressed: () => Navigator.pop(context), + child: Text("Cancel", style: TextStyle(color: AppTheme.egyptianBlue)), + ), + ], + ); + } + + Widget _buildActionTile({ + required String title, + required String subtitle, + required VoidCallback onTap, + IconData? icon, + Color? color, + }) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(15), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: (color ?? AppTheme.navyText).withOpacity(0.05), + borderRadius: BorderRadius.circular(15), + border: Border.all(color: (color ?? AppTheme.navyText).withOpacity(0.1)), + ), + child: Row( + children: [ + if (icon != null) ...[ + Icon(icon, color: color ?? AppTheme.navyText.withOpacity(0.7), size: 28), + const SizedBox(width: 16), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: AppTheme.labelLarge.copyWith(color: color ?? AppTheme.navyText)), + const SizedBox(height: 2), + Text(subtitle, style: AppTheme.labelSmall.copyWith(color: (color ?? AppTheme.navyText).withOpacity(0.6))), + ], + ), + ), + Icon(Icons.chevron_right, color: AppTheme.egyptianBlue.withOpacity(0.3)), + ], + ), + ), + ); + } + + Future _submitReport() async { + setState(() => _isProcessing = true); + try { + await ApiService.instance.callGoApi( + '/users/report', + method: 'POST', + body: { + 'target_user_id': widget.post.authorId, + 'post_id': widget.post.id, + 'violation_type': _violationType, + 'description': _descriptionController.text, + }, + ); + if (mounted) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("Report submitted. Thank you for maintaining harmony.")), + ); + } + } catch (e) { + if (mounted) setState(() => _isProcessing = false); + } + } + + Future _confirmBlock() async { + setState(() => _isProcessing = true); + try { + await ApiService.instance.callGoApi( + '/users/${widget.post.authorId}/block', + method: 'POST', + ); + if (mounted) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("Structural exclusion complete.")), + ); + } + } catch (e) { + if (mounted) setState(() => _isProcessing = false); + } + } +} diff --git a/sojorn_app/lib/widgets/post/markdown_post_body.dart b/sojorn_app/lib/widgets/post/markdown_post_body.dart index 6aea994..8b47fc6 100644 --- a/sojorn_app/lib/widgets/post/markdown_post_body.dart +++ b/sojorn_app/lib/widgets/post/markdown_post_body.dart @@ -3,7 +3,7 @@ import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:markdown/markdown.dart' as md; import '../../utils/external_link_controller.dart'; import '../../theme/app_theme.dart'; -import '../../screens/search/search_screen.dart'; +import '../../screens/discover/discover_screen.dart'; /// Simple widget to limit max lines for any child class LimitedMaxLinesBox extends StatelessWidget { @@ -162,7 +162,7 @@ class _MarkdownBodyContent extends StatelessWidget { final tag = href.replaceFirst('hashtag://', ''); Navigator.of(context).push( MaterialPageRoute( - builder: (_) => SearchScreen(initialQuery: '#$tag'), + builder: (_) => DiscoverScreen(initialQuery: '#$tag'), ), ); return; @@ -194,7 +194,7 @@ class HashtagSyntax extends md.InlineSyntax { } } -/// Renders hashtags as clickable text that routes to SearchScreen. +/// Renders hashtags as clickable text that routes to DiscoverScreen. class HashtagBuilder extends MarkdownElementBuilder { @override Widget? visitElementAfterWithContext( @@ -218,7 +218,7 @@ class HashtagBuilder extends MarkdownElementBuilder { onTap: () { Navigator.of(context).push( MaterialPageRoute( - builder: (_) => SearchScreen(initialQuery: displayText), + builder: (_) => DiscoverScreen(initialQuery: displayText), ), ); }, diff --git a/sojorn_app/lib/widgets/post/post_actions.dart b/sojorn_app/lib/widgets/post/post_actions.dart index 0097c83..95b3fcb 100644 --- a/sojorn_app/lib/widgets/post/post_actions.dart +++ b/sojorn_app/lib/widgets/post/post_actions.dart @@ -241,6 +241,7 @@ class _PostActionsState extends ConsumerState { ), style: IconButton.styleFrom( backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.08), + minimumSize: const Size(44, 44), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), @@ -255,6 +256,7 @@ class _PostActionsState extends ConsumerState { ), style: IconButton.styleFrom( backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.08), + minimumSize: const Size(44, 44), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), @@ -263,10 +265,8 @@ class _PostActionsState extends ConsumerState { ], ), - // Right side: Reply and Reactions Row( children: [ - // Single Authority: ReactionsDisplay in compact mode for the actions row ReactionsDisplay( reactionCounts: _reactionCounts, myReactions: _myReactions, @@ -283,7 +283,8 @@ class _PostActionsState extends ConsumerState { style: ElevatedButton.styleFrom( backgroundColor: AppTheme.brightNavy, foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + minimumSize: const Size(0, 44), + padding: const EdgeInsets.symmetric(horizontal: 16), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), diff --git a/sojorn_app/lib/widgets/post_with_video_widget.dart b/sojorn_app/lib/widgets/post_with_video_widget.dart index e6a6516..be77916 100644 --- a/sojorn_app/lib/widgets/post_with_video_widget.dart +++ b/sojorn_app/lib/widgets/post_with_video_widget.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:timeago/timeago.dart' as timeago; -import '../../models/post.dart'; -import '../../theme/app_theme.dart'; +import '../models/post.dart'; +import '../theme/app_theme.dart'; import 'media/signed_media_image.dart'; import 'video_thumbnail_widget.dart'; import 'post/post_actions.dart'; @@ -107,6 +107,7 @@ class PostWithVideoWidget extends StatelessWidget { ), ), ), + ), const SizedBox(width: 12), diff --git a/sojorn_app/lib/widgets/radial_menu_overlay.dart b/sojorn_app/lib/widgets/radial_menu_overlay.dart index ac9ee91..f8773d9 100644 --- a/sojorn_app/lib/widgets/radial_menu_overlay.dart +++ b/sojorn_app/lib/widgets/radial_menu_overlay.dart @@ -125,15 +125,6 @@ class _RadialMenuOverlayState extends State }, angle: startAngle, ), - _MenuItem( - icon: Icons.videocam_outlined, - label: 'Quip', - onTap: () { - widget.onDismiss(); - widget.onQuipTap(); - }, - angle: (startAngle + endAngle) / 2, // Middle (top) - ), _MenuItem( icon: Icons.location_on_outlined, label: 'Beacon', @@ -141,6 +132,15 @@ class _RadialMenuOverlayState extends State widget.onDismiss(); widget.onBeaconTap(); }, + angle: (startAngle + endAngle) / 2, // Middle (top) + ), + _MenuItem( + icon: Icons.videocam_outlined, + label: 'Quip', + onTap: () { + widget.onDismiss(); + widget.onQuipTap(); + }, angle: endAngle, ), ]; diff --git a/sojorn_app/lib/widgets/reactions/reactions_display.dart b/sojorn_app/lib/widgets/reactions/reactions_display.dart index c9e4a37..bfc4080 100644 --- a/sojorn_app/lib/widgets/reactions/reactions_display.dart +++ b/sojorn_app/lib/widgets/reactions/reactions_display.dart @@ -51,10 +51,19 @@ class ReactionsDisplay extends StatelessWidget { } Widget _buildCompactView() { - if (reactionCounts.isEmpty) { - return _ReactionAddButton(onTap: onAddReaction ?? () {}); - } + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (onAddReaction != null) ...[ + _ReactionAddButton(onTap: onAddReaction!), + if (reactionCounts.isNotEmpty) const SizedBox(width: 8), + ], + if (reactionCounts.isNotEmpty) _buildTopReactionChip(), + ], + ); + } + Widget _buildTopReactionChip() { // Priority: User's reaction > Top reaction String? displayEmoji; if (myReactions.isNotEmpty) { @@ -71,6 +80,7 @@ class ReactionsDisplay extends StatelessWidget { isSelected: myReactions.contains(displayEmoji), tooltipNames: reactionUsers?[displayEmoji], onTap: () => onToggleReaction?.call(displayEmoji!), + onLongPress: onAddReaction, ); } @@ -92,6 +102,7 @@ class ReactionsDisplay extends StatelessWidget { isSelected: myReactions.contains(entry.key), tooltipNames: reactionUsers?[entry.key], onTap: () => onToggleReaction?.call(entry.key), + onLongPress: onAddReaction, ); }), if (onAddReaction != null) @@ -108,12 +119,14 @@ class _ReactionChip extends StatefulWidget { final bool isSelected; final List? tooltipNames; final VoidCallback onTap; + final VoidCallback? onLongPress; const _ReactionChip({ required this.reactionId, required this.count, required this.isSelected, required this.onTap, + this.onLongPress, this.tooltipNames, }); @@ -136,9 +149,11 @@ class _ReactionChipState extends State<_ReactionChip> { final chip = GestureDetector( onTap: _handleTap, + onLongPress: widget.onLongPress, child: AnimatedContainer( duration: const Duration(milliseconds: 150), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + height: 44, + padding: const EdgeInsets.symmetric(horizontal: 10), decoration: BoxDecoration( color: isMyReaction ? AppTheme.brightNavy.withValues(alpha: 0.15) @@ -201,7 +216,8 @@ class _ReactionAddButton extends StatelessWidget { return GestureDetector( onTap: onTap, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + height: 44, + padding: const EdgeInsets.symmetric(horizontal: 10), decoration: BoxDecoration( color: AppTheme.navyBlue.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(12), diff --git a/sojorn_app/lib/widgets/sojorn_post_card.dart b/sojorn_app/lib/widgets/sojorn_post_card.dart index 216a861..52eed97 100644 --- a/sojorn_app/lib/widgets/sojorn_post_card.dart +++ b/sojorn_app/lib/widgets/sojorn_post_card.dart @@ -9,6 +9,8 @@ import 'post/post_menu.dart'; import 'post/post_view_mode.dart'; import 'chain_quote_widget.dart'; import '../routes/app_routes.dart'; +import 'modals/sanctuary_sheet.dart'; +import '../theme/sojorn_feed_palette.dart'; /// Unified Post Card - Single Source of Truth for post display. /// @@ -141,6 +143,22 @@ class sojornPostCard extends StatelessWidget { ), ), ), + GestureDetector( + onTap: () => SanctuarySheet.show(context, post), + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 4), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: AppTheme.ksuPurple.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Text("!", style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w900, + color: AppTheme.royalPurple.withOpacity(0.7), + )), + ), + ), PostMenu( post: post, onPostDeleted: onPostChanged, diff --git a/sojorn_app/lib/widgets/sojorn_rich_text.dart b/sojorn_app/lib/widgets/sojorn_rich_text.dart index 0b7b870..3eaac4d 100644 --- a/sojorn_app/lib/widgets/sojorn_rich_text.dart +++ b/sojorn_app/lib/widgets/sojorn_rich_text.dart @@ -2,7 +2,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import '../theme/app_theme.dart'; import '../utils/link_handler.dart'; -import '../screens/search/search_screen.dart'; +import '../screens/discover/discover_screen.dart'; /// Rich text widget that automatically detects and styles URLs and mentions. /// @@ -100,7 +100,7 @@ class sojornRichText extends StatelessWidget { // Navigate to search with hashtag query Navigator.of(context).push( MaterialPageRoute( - builder: (_) => SearchScreen(initialQuery: matchText), + builder: (_) => DiscoverScreen(initialQuery: matchText), ), ); } else { diff --git a/sojorn_app/lib/widgets/video_thumbnail_widget.dart b/sojorn_app/lib/widgets/video_thumbnail_widget.dart index d7a898f..5fbe581 100644 --- a/sojorn_app/lib/widgets/video_thumbnail_widget.dart +++ b/sojorn_app/lib/widgets/video_thumbnail_widget.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; -import '../../models/post.dart'; -import '../../theme/app_theme.dart'; -import '../media/signed_media_image.dart'; +import '../models/post.dart'; +import '../theme/app_theme.dart'; +import 'media/signed_media_image.dart'; /// Widget for displaying video thumbnails on regular posts (Twitter-style) /// Clicking opens the Quips feed with the full video diff --git a/sojorn_docs/features/fcm-implementation.md b/sojorn_docs/features/fcm-implementation.md new file mode 100644 index 0000000..8ee5c72 --- /dev/null +++ b/sojorn_docs/features/fcm-implementation.md @@ -0,0 +1,221 @@ +# FCM (Firebase Cloud Messaging) Implementation Guide + +## Overview + +This document describes the complete FCM push notification implementation for Sojorn, covering both the Go backend and Flutter client. The system supports Android, iOS, and Web platforms. + +--- + +## Architecture + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Flutter App │────▢│ Go Backend │────▢│ Firebase FCM β”‚ +β”‚ (Android/Web) β”‚ β”‚ Push Service β”‚ β”‚ Cloud Messaging β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ β”‚ + β”‚ POST /notifications/device β”‚ + β”‚ (Register FCM Token) β”‚ + β”‚ β”‚ β”‚ + β”‚ β”‚ SendPush() β”‚ + β”‚ │──────────────────────▢│ + β”‚ β”‚ β”‚ + │◀──────────────────────│◀──────────────────────│ + β”‚ Push Notification β”‚ β”‚ +``` + +--- + +## Backend Configuration + +### Required Environment Variables + +```env +# Firebase Cloud Messaging (FCM) +FIREBASE_CREDENTIALS_FILE=/opt/sojorn/firebase-service-account.json +FIREBASE_WEB_VAPID_KEY=BNxS7_your_actual_vapid_key_here +``` + +### Firebase Service Account JSON + +Download from Firebase Console > Project Settings > Service Accounts > Generate New Private Key + +The JSON file should contain: +- `project_id` +- `private_key` +- `client_email` + +--- + +## Database Schema + +### `user_fcm_tokens` Table + +```sql +CREATE TABLE IF NOT EXISTS user_fcm_tokens ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + token TEXT NOT NULL, + device_type TEXT, -- 'web', 'android', 'ios', 'desktop' + last_updated TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(user_id, token) -- Prevents duplicate tokens per device +); +``` + +**Key Features:** +- Composite unique constraint on `(user_id, token)` prevents duplicate registrations +- `device_type` column supports platform-specific logic +- `ON DELETE CASCADE` ensures tokens are cleaned up when user is deleted + +--- + +## Push Notification Triggers + +| Event | Handler | Recipient | Data Payload | +|-------|---------|-----------|--------------| +| New Follower | `user_handler.go:Follow` | Followed user | `type`, `follower_id` | +| Follow Request | `user_handler.go:Follow` | Target user | `type`, `follower_id` | +| Follow Accepted | `user_handler.go:AcceptFollowRequest` | Requester | `type`, `follower_id` | +| New Chat Message | `chat_handler.go:SendMessage` | Receiver | `type`, `conversation_id`, `encrypted` | +| Comment on Post | `post_handler.go:CreateComment` | Post author | `type`, `post_id`, `post_type`, `target` | +| Post Saved | `post_handler.go:SavePost` | Post author | `type`, `post_id`, `post_type`, `target` | +| Beacon Vouched | `post_handler.go:VouchBeacon` | Beacon author | `type`, `beacon_id`, `target` | +| Beacon Reported | `post_handler.go:ReportBeacon` | Beacon author | `type`, `beacon_id`, `target` | + +--- + +## Data Payload Structure + +All push notifications include a `data` payload for deep linking: + +```json +{ + "type": "comment|save|reply|chat|new_follower|follow_request|beacon_vouch", + "post_id": "uuid", // For post-related notifications + "conversation_id": "uuid", // For chat notifications + "follower_id": "uuid", // For follow notifications + "beacon_id": "uuid", // For beacon notifications + "target": "main_feed|quip_feed|beacon_map|secure_chat|profile|thread_view" +} +``` + +--- + +## Flutter Client Implementation + +### Initialization Flow + +1. **Android 13+ Permission Check**: Explicitly request `POST_NOTIFICATIONS` permission +2. **Firebase Permission Request**: Request FCM permissions via SDK +3. **Token Retrieval**: Get FCM token (with VAPID key for web) +4. **Backend Registration**: POST token to `/notifications/device` +5. **Set up Listeners**: Handle token refresh and message callbacks + +### Deep Linking (Message Open Handling) + +The Flutter client handles these notification types: + +| Type | Navigation Target | +|------|-------------------| +| `chat`, `new_message` | SecureChatScreen with conversation | +| `save`, `comment`, `reply` | Based on `target` field (home/quips/beacon) | +| `new_follower`, `follow_request` | Profile screen of follower | +| `beacon_vouch`, `beacon_report` | Beacon map | + +### Logout Flow + +On logout, the client: +1. Calls `DELETE /notifications/device` with the current token +2. Deletes the token from Firebase locally +3. Resets initialization state + +--- + +## API Endpoints + +### Register Device Token + +```http +POST /api/v1/notifications/device +Authorization: Bearer +Content-Type: application/json + +{ + "fcm_token": "device_token_here", + "platform": "android|ios|web|desktop" +} +``` + +### Unregister Device Token + +```http +DELETE /api/v1/notifications/device +Authorization: Bearer +Content-Type: application/json + +{ + "fcm_token": "device_token_here" +} +``` + +--- + +## Troubleshooting + +### Token Not Registering + +1. Check Firebase is initialized properly +2. Verify `FIREBASE_CREDENTIALS_FILE` path is correct +3. For web, ensure `FIREBASE_WEB_VAPID_KEY` is set +4. Check network connectivity to Go backend + +### Notifications Not Arriving + +1. Verify token exists in `user_fcm_tokens` table +2. Check Firebase Console for delivery reports +3. Ensure app hasn't restricted background data +4. On Android 13+, verify POST_NOTIFICATIONS permission + +### Invalid Token Cleanup + +The `PushService` automatically removes invalid tokens when FCM returns `messaging.IsRegistrationTokenNotRegistered` error. + +--- + +## Testing + +### Manual Token Verification + +```sql +SELECT user_id, token, device_type, last_updated +FROM user_fcm_tokens +WHERE user_id = 'your-user-uuid'; +``` + +### Send Test Notification + +Use Firebase Console > Cloud Messaging > Send test message with the device token. + +--- + +## Platform-Specific Notes + +### Android + +- Target SDK 33+ requires `POST_NOTIFICATIONS` runtime permission +- Add to `AndroidManifest.xml`: + ```xml + + ``` + +### Web + +- Requires valid VAPID key from Firebase Console +- Service worker must be properly configured +- HTTPS required (except localhost) + +### iOS + +- Requires APNs configuration in Firebase +- Provisioning profile must include push notification capability diff --git a/sojorn_docs/features/notifications-troubleshooting.md b/sojorn_docs/features/notifications-troubleshooting.md index 5bafd45..4528a2e 100644 --- a/sojorn_docs/features/notifications-troubleshooting.md +++ b/sojorn_docs/features/notifications-troubleshooting.md @@ -1,33 +1,111 @@ -# Notifications Troubleshooting and Fix +# Notifications Troubleshooting (Go Backend) -## Symptoms -- Notifications screen fails to load and logs show a `GET | 401` response from - `supabase/functions/v1/notifications`. -- Edge function logs show `Unauthorized` even though the client is signed in. +> **Note**: This document has been updated for the 100% Go backend migration. Legacy Supabase edge function references are no longer applicable. -## Root Cause -The notifications edge function relied on `supabaseClient.auth.getUser()` without -explicitly passing the bearer token from the request. In some cases, the global -headers were not applied as expected, so `getUser()` could not resolve the user -and returned 401. +## Current Architecture -## Fix -Explicitly read the `Authorization` header and pass the token to -`supabaseClient.auth.getUser(token)`. This ensures the function authenticates the -user consistently even if the SDK does not automatically inject the header. +All notification APIs now use the Go backend with JWT authentication: +- **Register Token**: `POST /api/v1/notifications/device` +- **Unregister Token**: `DELETE /api/v1/notifications/device` +- **Get Notifications**: `GET /api/v1/notifications` -## Code Change -File: `supabase/functions/notifications/index.ts` +Authentication uses `Authorization: Bearer ` header with Go-issued JWTs. -Key update: -- Parse `Authorization` header. -- Extract bearer token. -- Call `getUser(token)` instead of `getUser()` without arguments. +--- -## Deployment Step -Redeploy the `notifications` edge function so the new auth flow is used. +## Common Issues -## Verification -- Open the notifications screen. -- Confirm the request returns 200 and notifications render. -- If it still fails, check edge function logs for missing or empty auth headers. +### 1. Token Not Syncing to Backend + +**Symptoms:** +- FCM token obtained successfully but not stored in database +- Debug logs show `[FCM] Token synced with Go Backend successfully` not appearing + +**Solutions:** +1. Verify user is authenticated before calling `NotificationService.init()` +2. Check API endpoint responds (network tab / logs) +3. Ensure JWT token is valid and not expired + +### 2. Push Notifications Not Received + +**Symptoms:** +- Token exists in database +- No push received on device + +**Diagnosis:** +```sql +-- Check if token exists for user +SELECT * FROM user_fcm_tokens WHERE user_id = 'your-uuid'; +``` + +**Solutions:** +1. Verify `FIREBASE_CREDENTIALS_FILE` path is correct in backend `.env` +2. Check Firebase Console for delivery reports +3. For web, verify `FIREBASE_WEB_VAPID_KEY` matches Firebase Console +4. On Android 13+, check `POST_NOTIFICATIONS` permission granted + +### 3. Android 13+ Permission Denied + +**Symptoms:** +- `[FCM] Android notification permission not granted: denied` + +**Solution:** +The app now properly requests `POST_NOTIFICATIONS` at runtime. If user denied: +1. Guide them to Settings > Apps > Sojorn > Notifications +2. Enable notifications manually + +### 4. Web Push Not Working + +**Symptoms:** +- Token is null on web platform +- `[FCM] Web push is missing FIREBASE_WEB_VAPID_KEY` + +**Solutions:** +1. Verify VAPID key in `lib/config/firebase_web_config.dart` +2. Key must match Firebase Console > Cloud Messaging > Web Push certificates +3. Must be served over HTTPS (except localhost) + +### 5. Duplicate Tokens in Database + +The schema now has a unique constraint on `(user_id, token)`: +```sql +UNIQUE(user_id, token) +``` + +This prevents duplicates via upsert logic: +```sql +ON CONFLICT (user_id, token) DO UPDATE SET last_updated = ... +``` + +--- + +## Logout Cleanup + +On logout, the Flutter client now: +1. Calls backend to delete token from `user_fcm_tokens` +2. Deletes token from Firebase locally via `deleteToken()` +3. Resets initialization state + +This ensures the device no longer receives notifications for the logged-out user. + +--- + +## Testing Checklist + +- [ ] Token registration works on Android +- [ ] Token registration works on Web +- [ ] Token appears in `user_fcm_tokens` table with correct `device_type` +- [ ] New message triggers push to recipient +- [ ] Post save triggers push to author +- [ ] Comment triggers push to post author +- [ ] Follow triggers push to followed user +- [ ] Tapping notification navigates correctly +- [ ] Logout removes token from database +- [ ] Re-login registers new token + +--- + +## Related Documentation + +- [FCM Implementation Guide](./fcm-implementation.md) - Complete implementation details +- [Backend API Documentation](../api/) - All API endpoints