Update terminology, fix search feed, and deploy updates

This commit is contained in:
Patrick Britton 2026-02-03 21:44:08 -06:00
parent 403f522a0b
commit 002f960142
51 changed files with 6700 additions and 918 deletions

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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)
);

View file

@ -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)
);

View file

@ -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

View file

@ -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)
}
}

View file

@ -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"})
}

View file

@ -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"})
}

View file

@ -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"})
}

View file

@ -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"`
}

View file

@ -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"`
}

View file

@ -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 {

View file

@ -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"`
}

View file

@ -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 {

View file

@ -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(&notif.ID, &notif.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(&notif.ID, &notif.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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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 {

View file

@ -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
}

View file

@ -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;

View file

@ -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<String, dynamic> 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,
);
}
}

View file

@ -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<String, dynamic> 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<String, dynamic> 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) {

View file

@ -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<SettingsState> {
final ApiService _apiService;
SettingsNotifier(this._apiService) : super(SettingsState()) {
refresh();
}
Future<void> 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<void> 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<void> 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<void> 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<SettingsNotifier, SettingsState>((ref) {
return SettingsNotifier(ApiService.instance);
});

View file

@ -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<UploadProgress> {
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<UploadNotifier, UploadProgress>((ref) {
return UploadNotifier();
});

View file

@ -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(),
),
],
),
],
),

View file

@ -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<Hashtag> topTags;
final List<Post> popularPosts;
DiscoverData({
required this.topTags,
required this.popularPosts,
});
factory DiscoverData.fromJson(Map<String, dynamic> 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<String, dynamic> 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<DiscoverScreen> createState() => _DiscoverScreenState();
}
class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
final TextEditingController searchController = TextEditingController();
final FocusNode focusNode = FocusNode();
Timer? debounceTimer;
bool isLoadingSearch = false;
bool isLoadingDiscover = true;
bool hasSearched = false;
SearchResults? searchResults;
DiscoverData? discoverData;
List<RecentSearch> recentSearches = [];
int _searchEpoch = 0;
final Map<String, Future<Post>> _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<void> 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<void> 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<void> 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<void> 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<void> 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<Post> _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<Post>(
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),
),
);
},
);
}
}

View file

@ -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<HomeShell> 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<Color>(
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<HomeShell> 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<HomeShell> 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(

View file

@ -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<BlockedUsersScreen> createState() => _BlockedUsersScreenState();
}
class _BlockedUsersScreenState extends ConsumerState<BlockedUsersScreen> {
bool _isLoading = true;
List<Profile> _blockedUsers = [];
String? _error;
@override
void initState() {
super.initState();
_loadBlockedUsers();
}
Future<void> _loadBlockedUsers() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final apiService = ref.read(apiServiceProvider);
final response = await apiService.callGoApi('/users/blocked', method: 'GET');
final List<dynamic> 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<void> _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<void> _exportBlockList() async {
try {
final List<String> 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<void> _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<dynamic> 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'),
),
);
},
),
);
}
}

View file

@ -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<CategoryDiscoveryScreen> createState() => _CategoryDiscoveryScreenState();
}
class _CategoryDiscoveryScreenState extends ConsumerState<CategoryDiscoveryScreen> {
bool _isLoading = true;
List<Category> _categories = [];
Map<String, bool> _settings = {};
@override
void initState() {
super.initState();
_loadData();
}
Future<void> _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<dynamic> catsJson = catsResponse['categories'] ?? [];
final List<dynamic> 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<void> _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),
),
);
},
),
);
}
}

File diff suppressed because it is too large Load diff

View file

@ -31,14 +31,19 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
SearchResults? results;
List<RecentSearch> recentSearches = [];
int _searchEpoch = 0;
final Map<String, Future<Post>> _postFutures = {};
// Discovery State
bool _isDiscoveryLoading = false;
List<Post> _discoveryPosts = [];
static const Duration debounceDuration = Duration(milliseconds: 250);
static const List<String> trendingTags = [
'safety',
'wellness',
'growth',
'mindfulness',
'focus',
'community',
'insights',
'reflection',
@ -62,6 +67,25 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
focusNode.requestFocus();
});
}
_loadDiscoveryContent();
}
Future<void> _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<SearchScreen> {
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<SearchScreen> {
),
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<SearchScreen> {
),
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<SearchScreen> {
);
}
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<SearchScreen> {
);
},
),
),
],
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),
],
],
),
);
}

View file

@ -170,6 +170,12 @@ class ApiService {
return [];
}
/// Simple GET request helper
Future<Map<String, dynamic>> get(String path, {Map<String, String>? queryParams}) async {
return _callGoApi(path, method: 'GET', queryParams: queryParams);
}
Future<void> 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<void> vouchBeacon(String beaconId) async {
await _callGoApi(
'/beacons/$beaconId/vouch',
method: 'POST',
);
}
Future<void> reportBeacon(String beaconId) async {
await _callGoApi(
'/beacons/$beaconId/report',
method: 'POST',
);
}
Future<void> 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<List<Post>> getsojornFeed({int limit = 20, int offset = 0}) async {
Future<List<Post>> getSojornFeed({int limit = 20, int offset = 0}) async {
return getPersonalFeed(limit: limit, offset: offset);
}
@ -1095,6 +1080,31 @@ class ApiService {
);
}
// =========================================================================
// Beacon Actions
// =========================================================================
Future<void> vouchBeacon(String beaconId) async {
await callGoApi(
'/beacons/$beaconId/vouch',
method: 'POST',
);
}
Future<void> reportBeacon(String beaconId) async {
await callGoApi(
'/beacons/$beaconId/report',
method: 'POST',
);
}
Future<void> removeBeaconVote(String beaconId) async {
await callGoApi(
'/beacons/$beaconId/vouch',
method: 'DELETE',
);
}
// =========================================================================
// Key Backup & Recovery
// =========================================================================

View file

@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<UnreadBadge> _badgeController = StreamController<UnreadBadge>.broadcast();
Stream<UnreadBadge> get badgeStream => _badgeController.stream;
UnreadBadge _currentBadge = UnreadBadge();
UnreadBadge get currentBadge => _currentBadge;
// Foreground notification stream for in-app banners
final StreamController<RemoteMessage> _foregroundMessageController = StreamController<RemoteMessage>.broadcast();
Stream<RemoteMessage> get foregroundMessages => _foregroundMessageController.stream;
// Global overlay entry for in-app notification banner
OverlayEntry? _currentBannerOverlay;
Future<void> 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<PermissionStatus> _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<void> 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<String?> _resolveVapidKey() async {
@ -151,19 +346,203 @@ class NotificationService {
return null;
}
// ============================================================================
// Badge Count Management
// ============================================================================
Future<void> _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<void> refreshBadge() => _refreshBadgeCount();
// ============================================================================
// Preferences Management
// ============================================================================
Future<NotificationPreferences> 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<bool> 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<void> 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<void> 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<void> _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<void> _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<Offset> _slideAnimation;
late Animation<double> _fadeAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_slideAnimation = Tween<Offset>(
begin: const Offset(0, -1),
end: Offset.zero,
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
_fadeAnimation = Tween<double>(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,
),
),
),
],
),
),
),
),
),
),
);
}
}

View file

@ -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!;

View file

@ -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<void> show(BuildContext context, Post post) {
return showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (_) => SanctuarySheet(post: post),
);
}
@override
State<SanctuarySheet> createState() => _SanctuarySheetState();
}
class _SanctuarySheetState extends State<SanctuarySheet> {
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<void> _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<void> _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);
}
}
}

View file

@ -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),
),
);
},

View file

@ -241,6 +241,7 @@ class _PostActionsState extends ConsumerState<PostActions> {
),
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<PostActions> {
),
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<PostActions> {
],
),
// 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<PostActions> {
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),
),

View file

@ -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),

View file

@ -125,15 +125,6 @@ class _RadialMenuOverlayState extends State<RadialMenuOverlay>
},
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<RadialMenuOverlay>
widget.onDismiss();
widget.onBeaconTap();
},
angle: (startAngle + endAngle) / 2, // Middle (top)
),
_MenuItem(
icon: Icons.videocam_outlined,
label: 'Quip',
onTap: () {
widget.onDismiss();
widget.onQuipTap();
},
angle: endAngle,
),
];

View file

@ -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<String>? 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),

View file

@ -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,

View file

@ -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 {

View file

@ -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

View file

@ -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 <JWT>
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 <JWT>
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
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
```
### 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

View file

@ -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 <token>` 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