Update terminology, fix search feed, and deploy updates
This commit is contained in:
parent
403f522a0b
commit
002f960142
|
|
@ -99,6 +99,7 @@ func main() {
|
||||||
chatRepo := repository.NewChatRepository(dbPool)
|
chatRepo := repository.NewChatRepository(dbPool)
|
||||||
categoryRepo := repository.NewCategoryRepository(dbPool)
|
categoryRepo := repository.NewCategoryRepository(dbPool)
|
||||||
notifRepo := repository.NewNotificationRepository(dbPool)
|
notifRepo := repository.NewNotificationRepository(dbPool)
|
||||||
|
tagRepo := repository.NewTagRepository(dbPool)
|
||||||
|
|
||||||
assetService := services.NewAssetService(cfg.R2SigningSecret, cfg.R2PublicBaseURL, cfg.R2ImgDomain, cfg.R2VidDomain)
|
assetService := services.NewAssetService(cfg.R2SigningSecret, cfg.R2PublicBaseURL, cfg.R2ImgDomain, cfg.R2VidDomain)
|
||||||
feedService := services.NewFeedService(postRepo, assetService)
|
feedService := services.NewFeedService(postRepo, assetService)
|
||||||
|
|
@ -108,15 +109,16 @@ func main() {
|
||||||
log.Warn().Err(err).Msg("Failed to initialize PushService")
|
log.Warn().Err(err).Msg("Failed to initialize PushService")
|
||||||
}
|
}
|
||||||
|
|
||||||
notificationService := services.NewNotificationService(notifRepo, pushService)
|
notificationService := services.NewNotificationService(notifRepo, pushService, userRepo)
|
||||||
|
|
||||||
emailService := services.NewEmailService(cfg)
|
emailService := services.NewEmailService(cfg)
|
||||||
|
moderationService := services.NewModerationService(dbPool)
|
||||||
|
|
||||||
hub := realtime.NewHub()
|
hub := realtime.NewHub()
|
||||||
wsHandler := handlers.NewWSHandler(hub, cfg.JWTSecret)
|
wsHandler := handlers.NewWSHandler(hub, cfg.JWTSecret)
|
||||||
|
|
||||||
userHandler := handlers.NewUserHandler(userRepo, postRepo, pushService, assetService)
|
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)
|
chatHandler := handlers.NewChatHandler(chatRepo, pushService, hub)
|
||||||
authHandler := handlers.NewAuthHandler(userRepo, cfg, emailService)
|
authHandler := handlers.NewAuthHandler(userRepo, cfg, emailService)
|
||||||
categoryHandler := handlers.NewCategoryHandler(categoryRepo)
|
categoryHandler := handlers.NewCategoryHandler(categoryRepo)
|
||||||
|
|
@ -203,6 +205,13 @@ func main() {
|
||||||
users.GET("/:id/posts", postHandler.GetProfilePosts)
|
users.GET("/:id/posts", postHandler.GetProfilePosts)
|
||||||
users.GET("/:id/saved", userHandler.GetSavedPosts)
|
users.GET("/:id/saved", userHandler.GetSavedPosts)
|
||||||
users.GET("/me/liked", userHandler.GetLikedPosts)
|
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)
|
authorized.POST("/posts", postHandler.CreatePost)
|
||||||
|
|
@ -271,15 +280,29 @@ func main() {
|
||||||
// Media routes
|
// Media routes
|
||||||
authorized.POST("/upload", mediaHandler.Upload)
|
authorized.POST("/upload", mediaHandler.Upload)
|
||||||
|
|
||||||
// Search route
|
// Search & Discover routes
|
||||||
searchHandler := handlers.NewSearchHandler(userRepo, postRepo, assetService)
|
discoverHandler := handlers.NewDiscoverHandler(userRepo, postRepo, tagRepo, categoryRepo, assetService)
|
||||||
authorized.GET("/search", searchHandler.Search)
|
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
|
// Notifications
|
||||||
notificationHandler := handlers.NewNotificationHandler(notifRepo)
|
notificationHandler := handlers.NewNotificationHandler(notifRepo, notificationService)
|
||||||
authorized.GET("/notifications", notificationHandler.GetNotifications)
|
authorized.GET("/notifications", notificationHandler.GetNotifications)
|
||||||
authorized.POST("/notifications/device", settingsHandler.RegisterDevice)
|
authorized.GET("/notifications/unread", notificationHandler.GetUnreadCount)
|
||||||
authorized.DELETE("/notifications/device", settingsHandler.UnregisterDevice)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
85
go-backend/cmd/migrate_new/main.go
Normal file
85
go-backend/cmd/migrate_new/main.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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)
|
||||||
|
);
|
||||||
|
|
@ -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)
|
||||||
|
);
|
||||||
|
|
@ -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
|
||||||
367
go-backend/internal/handlers/discover_handler.go
Normal file
367
go-backend/internal/handlers/discover_handler.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,46 +2,281 @@ package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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/repository"
|
||||||
|
"github.com/patbritton/sojorn-backend/internal/services"
|
||||||
|
"github.com/patbritton/sojorn-backend/pkg/utils"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
type NotificationHandler struct {
|
type NotificationHandler struct {
|
||||||
repo *repository.NotificationRepository
|
notifRepo *repository.NotificationRepository
|
||||||
|
notifService *services.NotificationService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewNotificationHandler(repo *repository.NotificationRepository) *NotificationHandler {
|
func NewNotificationHandler(notifRepo *repository.NotificationRepository, notifService *services.NotificationService) *NotificationHandler {
|
||||||
return &NotificationHandler{repo: repo}
|
return &NotificationHandler{
|
||||||
|
notifRepo: notifRepo,
|
||||||
|
notifService: notifService,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetNotifications retrieves paginated notifications for the user
|
||||||
|
// GET /api/v1/notifications
|
||||||
func (h *NotificationHandler) GetNotifications(c *gin.Context) {
|
func (h *NotificationHandler) GetNotifications(c *gin.Context) {
|
||||||
userIdStr, exists := c.Get("user_id")
|
userIDStr, exists := c.Get("user_id")
|
||||||
if !exists {
|
if !exists {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
limit := 20
|
limit := utils.GetQueryInt(c, "limit", 20)
|
||||||
offset := 0
|
offset := utils.GetQueryInt(c, "offset", 0)
|
||||||
|
grouped := c.Query("grouped") == "true"
|
||||||
|
|
||||||
if l := c.Query("limit"); l != "" {
|
var notifications []models.Notification
|
||||||
if val, err := strconv.Atoi(l); err == nil {
|
var err error
|
||||||
limit = val
|
|
||||||
}
|
if grouped {
|
||||||
}
|
notifications, err = h.notifRepo.GetGroupedNotifications(c.Request.Context(), userIDStr.(string), limit, offset)
|
||||||
if o := c.Query("offset"); o != "" {
|
} else {
|
||||||
if val, err := strconv.Atoi(o); err == nil {
|
notifications, err = h.notifRepo.GetNotifications(c.Request.Context(), userIDStr.(string), limit, offset)
|
||||||
offset = val
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
notifications, err := h.repo.GetNotifications(c.Request.Context(), userIdStr.(string), limit, offset)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to fetch notifications")
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch notifications"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch notifications"})
|
||||||
return
|
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"})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,15 +20,17 @@ type PostHandler struct {
|
||||||
feedService *services.FeedService
|
feedService *services.FeedService
|
||||||
assetService *services.AssetService
|
assetService *services.AssetService
|
||||||
notificationService *services.NotificationService
|
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{
|
return &PostHandler{
|
||||||
postRepo: postRepo,
|
postRepo: postRepo,
|
||||||
userRepo: userRepo,
|
userRepo: userRepo,
|
||||||
feedService: feedService,
|
feedService: feedService,
|
||||||
assetService: assetService,
|
assetService: assetService,
|
||||||
notificationService: notificationService,
|
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
|
// Create post
|
||||||
err = h.postRepo.CreatePost(c.Request.Context(), post)
|
err = h.postRepo.CreatePost(c.Request.Context(), post)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -243,6 +261,20 @@ func (h *PostHandler) CreatePost(c *gin.Context) {
|
||||||
return
|
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{
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
"post": post,
|
"post": post,
|
||||||
"tags": tags,
|
"tags": tags,
|
||||||
|
|
@ -409,6 +441,31 @@ func (h *PostHandler) LikePost(c *gin.Context) {
|
||||||
return
|
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"})
|
c.JSON(http.StatusOK, gin.H{"message": "Post liked"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -435,6 +492,42 @@ func (h *PostHandler) SavePost(c *gin.Context) {
|
||||||
return
|
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"})
|
c.JSON(http.StatusOK, gin.H{"message": "Post saved"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -355,3 +355,156 @@ func (h *UserHandler) RejectFollowRequest(c *gin.Context) {
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Follow request rejected"})
|
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"})
|
||||||
|
}
|
||||||
|
|
|
||||||
37
go-backend/internal/models/moderation.go
Normal file
37
go-backend/internal/models/moderation.go
Normal 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"`
|
||||||
|
}
|
||||||
|
|
@ -7,22 +7,53 @@ import (
|
||||||
"github.com/google/uuid"
|
"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 {
|
type Notification struct {
|
||||||
ID uuid.UUID `json:"id" db:"id"`
|
ID uuid.UUID `json:"id" db:"id"`
|
||||||
UserID uuid.UUID `json:"user_id" db:"user_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"`
|
ActorID uuid.UUID `json:"actor_id" db:"actor_id"`
|
||||||
PostID *uuid.UUID `json:"post_id,omitempty" db:"post_id"`
|
PostID *uuid.UUID `json:"post_id,omitempty" db:"post_id"`
|
||||||
CommentID *uuid.UUID `json:"comment_id,omitempty" db:"comment_id"`
|
CommentID *uuid.UUID `json:"comment_id,omitempty" db:"comment_id"`
|
||||||
IsRead bool `json:"is_read" db:"is_read"`
|
IsRead bool `json:"is_read" db:"is_read"`
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
Metadata json.RawMessage `json:"metadata" db:"metadata"`
|
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"`
|
ActorHandle string `json:"actor_handle" db:"actor_handle"`
|
||||||
ActorDisplayName string `json:"actor_display_name" db:"actor_display_name"`
|
ActorDisplayName string `json:"actor_display_name" db:"actor_display_name"`
|
||||||
ActorAvatarURL string `json:"actor_avatar_url" db:"actor_avatar_url"`
|
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 {
|
type UserFCMToken struct {
|
||||||
|
|
@ -32,3 +63,68 @@ type UserFCMToken struct {
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at" db:"updated_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"`
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ type PrivacySettings struct {
|
||||||
PostsVisibility *string `json:"posts_visibility" db:"posts_visibility"`
|
PostsVisibility *string `json:"posts_visibility" db:"posts_visibility"`
|
||||||
SavedVisibility *string `json:"saved_visibility" db:"saved_visibility"`
|
SavedVisibility *string `json:"saved_visibility" db:"saved_visibility"`
|
||||||
FollowRequestPolicy *string `json:"follow_request_policy" db:"follow_request_policy"`
|
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"`
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
90
go-backend/internal/models/tag.go
Normal file
90
go-backend/internal/models/tag.go
Normal 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"`
|
||||||
|
}
|
||||||
|
|
@ -35,6 +35,7 @@ type Profile struct {
|
||||||
CoverURL *string `json:"cover_url" db:"cover_url"`
|
CoverURL *string `json:"cover_url" db:"cover_url"`
|
||||||
IsOfficial *bool `json:"is_official" db:"is_official"`
|
IsOfficial *bool `json:"is_official" db:"is_official"`
|
||||||
IsPrivate *bool `json:"is_private" db:"is_private"`
|
IsPrivate *bool `json:"is_private" db:"is_private"`
|
||||||
|
IsVerified *bool `json:"is_verified" db:"is_verified"`
|
||||||
BeaconEnabled bool `json:"beacon_enabled" db:"beacon_enabled"`
|
BeaconEnabled bool `json:"beacon_enabled" db:"beacon_enabled"`
|
||||||
Location *string `json:"location" db:"location"`
|
Location *string `json:"location" db:"location"`
|
||||||
Website *string `json:"website" db:"website"`
|
Website *string `json:"website" db:"website"`
|
||||||
|
|
@ -48,6 +49,10 @@ type Profile struct {
|
||||||
Role string `json:"role" db:"role"`
|
Role string `json:"role" db:"role"`
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at" db:"updated_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 {
|
type Follow struct {
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,13 @@ package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/patbritton/sojorn-backend/internal/models"
|
"github.com/patbritton/sojorn-backend/internal/models"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
type NotificationRepository struct {
|
type NotificationRepository struct {
|
||||||
|
|
@ -16,6 +19,10 @@ func NewNotificationRepository(pool *pgxpool.Pool) *NotificationRepository {
|
||||||
return &NotificationRepository{pool: pool}
|
return &NotificationRepository{pool: pool}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// FCM Token Management
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
func (r *NotificationRepository) UpsertFCMToken(ctx context.Context, token *models.UserFCMToken) error {
|
func (r *NotificationRepository) UpsertFCMToken(ctx context.Context, token *models.UserFCMToken) error {
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO public.user_fcm_tokens (user_id, token, device_type, created_at, last_updated)
|
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 {
|
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
|
DELETE FROM public.user_fcm_tokens
|
||||||
WHERE user_id = $1::uuid AND token = $2
|
WHERE user_id = $1::uuid AND token = $2
|
||||||
`, userID, token)
|
`, userID, token)
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if commandTag.RowsAffected() == 0 {
|
|
||||||
return nil
|
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
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
|
// ============================================================================
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
err := r.pool.QueryRow(ctx, query,
|
||||||
|
notif.UserID, notif.Type, notif.ActorID, notif.PostID, notif.CommentID, notif.IsRead, notif.Metadata, notif.GroupKey, priority,
|
||||||
|
).Scan(¬if.ID, ¬if.CreatedAt)
|
||||||
|
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *NotificationRepository) GetNotifications(ctx context.Context, userID string, limit, offset int) ([]models.Notification, error) {
|
func (r *NotificationRepository) GetNotifications(ctx context.Context, userID string, limit, offset int) ([]models.Notification, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT
|
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,
|
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, ''),
|
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
|
FROM public.notifications n
|
||||||
JOIN public.profiles pr ON n.actor_id = pr.id
|
JOIN public.profiles pr ON n.actor_id = pr.id
|
||||||
LEFT JOIN public.posts po ON n.post_id = po.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{}
|
notifications := []models.Notification{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var n models.Notification
|
var n models.Notification
|
||||||
|
var groupKey string
|
||||||
var postImageURL *string
|
var postImageURL *string
|
||||||
|
var postBody *string
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&n.ID, &n.UserID, &n.Type, &n.ActorID, &n.PostID, &n.CommentID, &n.IsRead, &n.CreatedAt, &n.Metadata,
|
&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.ActorHandle, &n.ActorDisplayName, &n.ActorAvatarURL,
|
||||||
&postImageURL,
|
&postImageURL, &postBody,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if groupKey != "" {
|
||||||
|
n.GroupKey = &groupKey
|
||||||
|
}
|
||||||
n.PostImageURL = postImageURL
|
n.PostImageURL = postImageURL
|
||||||
|
n.PostBody = postBody
|
||||||
notifications = append(notifications, n)
|
notifications = append(notifications, n)
|
||||||
}
|
}
|
||||||
return notifications, nil
|
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 := `
|
query := `
|
||||||
INSERT INTO public.notifications (user_id, type, actor_id, post_id, comment_id, is_read, metadata)
|
WITH ranked AS (
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
SELECT
|
||||||
RETURNING id, created_at
|
n.*,
|
||||||
|
pr.handle as actor_handle,
|
||||||
|
pr.display_name as actor_display_name,
|
||||||
|
COALESCE(pr.avatar_url, '') as actor_avatar_url,
|
||||||
|
po.image_url as post_image_url,
|
||||||
|
LEFT(po.body, 100) as post_body,
|
||||||
|
COUNT(*) OVER (PARTITION BY n.group_key) as group_count,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY n.group_key ORDER BY n.created_at DESC) as rn
|
||||||
|
FROM public.notifications n
|
||||||
|
JOIN public.profiles pr ON n.actor_id = pr.id
|
||||||
|
LEFT JOIN public.posts po ON n.post_id = po.id
|
||||||
|
WHERE n.user_id = $1::uuid
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id, user_id, type, actor_id, post_id, comment_id, is_read, created_at, metadata,
|
||||||
|
COALESCE(group_key, '') as group_key,
|
||||||
|
COALESCE(priority, 'normal') as priority,
|
||||||
|
actor_handle, actor_display_name, actor_avatar_url,
|
||||||
|
post_image_url, post_body,
|
||||||
|
group_count
|
||||||
|
FROM ranked
|
||||||
|
WHERE rn = 1 OR group_key IS NULL
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $2 OFFSET $3
|
||||||
`
|
`
|
||||||
err := r.pool.QueryRow(ctx, query,
|
|
||||||
notif.UserID, notif.Type, notif.ActorID, notif.PostID, notif.CommentID, notif.IsRead, notif.Metadata,
|
|
||||||
).Scan(¬if.ID, ¬if.CreatedAt)
|
|
||||||
|
|
||||||
|
rows, err := r.pool.Query(ctx, query, userID, limit, offset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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'
|
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 ($3 = FALSE OR (COALESCE(p.video_url, '') <> '' OR (COALESCE(p.image_url, '') ILIKE '%.mp4')))
|
||||||
AND ($5 = '' OR c.slug = $5)
|
AND ($5 = '' OR c.slug = $5)
|
||||||
ORDER BY p.created_at DESC
|
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'
|
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
|
var p models.Post
|
||||||
err := r.pool.QueryRow(ctx, query, postID, userID).Scan(
|
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
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
569
go-backend/internal/repository/tag_repository.go
Normal file
569
go-backend/internal/repository/tag_repository.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -314,6 +314,79 @@ func (r *UserRepository) UnfollowUser(ctx context.Context, followerID, following
|
||||||
return nil
|
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 {
|
type ProfileStats struct {
|
||||||
PostCount int `json:"post_count"`
|
PostCount int `json:"post_count"`
|
||||||
FollowerCount int `json:"follower_count"`
|
FollowerCount int `json:"follower_count"`
|
||||||
|
|
@ -570,20 +643,23 @@ func (r *UserRepository) GetSignalKeyBundle(ctx context.Context, userID string)
|
||||||
func (r *UserRepository) GetPrivacySettings(ctx context.Context, userID string) (*models.PrivacySettings, error) {
|
func (r *UserRepository) GetPrivacySettings(ctx context.Context, userID string) (*models.PrivacySettings, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT user_id, show_location, show_interests, profile_visibility,
|
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
|
FROM public.profile_privacy_settings
|
||||||
WHERE user_id = $1::uuid
|
WHERE user_id = $1::uuid
|
||||||
`
|
`
|
||||||
var ps models.PrivacySettings
|
var ps models.PrivacySettings
|
||||||
err := r.pool.QueryRow(ctx, query, userID).Scan(
|
err := r.pool.QueryRow(ctx, query, userID).Scan(
|
||||||
&ps.UserID, &ps.ShowLocation, &ps.ShowInterests, &ps.ProfileVisibility,
|
&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 != nil {
|
||||||
if err.Error() == "no rows in result set" || err.Error() == "pgx: no rows in result set" {
|
if err.Error() == "no rows in result set" || err.Error() == "pgx: no rows in result set" {
|
||||||
// Return default settings for new users (pointers required)
|
// Return default settings for new users (pointers required)
|
||||||
uid, _ := uuid.Parse(userID)
|
uid, _ := uuid.Parse(userID)
|
||||||
t := true
|
t := true
|
||||||
|
f := false
|
||||||
pub := "public"
|
pub := "public"
|
||||||
priv := "private"
|
priv := "private"
|
||||||
anyone := "everyone"
|
anyone := "everyone"
|
||||||
|
|
@ -595,6 +671,8 @@ func (r *UserRepository) GetPrivacySettings(ctx context.Context, userID string)
|
||||||
PostsVisibility: &pub,
|
PostsVisibility: &pub,
|
||||||
SavedVisibility: &priv,
|
SavedVisibility: &priv,
|
||||||
FollowRequestPolicy: &anyone,
|
FollowRequestPolicy: &anyone,
|
||||||
|
DefaultPostVisibility: &pub,
|
||||||
|
IsPrivateProfile: &f,
|
||||||
UpdatedAt: time.Now(),
|
UpdatedAt: time.Now(),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -607,8 +685,9 @@ func (r *UserRepository) UpdatePrivacySettings(ctx context.Context, ps *models.P
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO public.profile_privacy_settings (
|
INSERT INTO public.profile_privacy_settings (
|
||||||
user_id, show_location, show_interests, profile_visibility,
|
user_id, show_location, show_interests, profile_visibility,
|
||||||
posts_visibility, saved_visibility, follow_request_policy, updated_at
|
posts_visibility, saved_visibility, follow_request_policy,
|
||||||
) VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, NOW())
|
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
|
ON CONFLICT (user_id) DO UPDATE SET
|
||||||
show_location = COALESCE(EXCLUDED.show_location, profile_privacy_settings.show_location),
|
show_location = COALESCE(EXCLUDED.show_location, profile_privacy_settings.show_location),
|
||||||
show_interests = COALESCE(EXCLUDED.show_interests, profile_privacy_settings.show_interests),
|
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),
|
posts_visibility = COALESCE(EXCLUDED.posts_visibility, profile_privacy_settings.posts_visibility),
|
||||||
saved_visibility = COALESCE(EXCLUDED.saved_visibility, profile_privacy_settings.saved_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),
|
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()
|
updated_at = NOW()
|
||||||
`
|
`
|
||||||
_, err := r.pool.Exec(ctx, query,
|
_, err := r.pool.Exec(ctx, query,
|
||||||
ps.UserID, ps.ShowLocation, ps.ShowInterests, ps.ProfileVisibility,
|
ps.UserID, ps.ShowLocation, ps.ShowInterests, ps.ProfileVisibility,
|
||||||
ps.PostsVisibility, ps.SavedVisibility, ps.FollowRequestPolicy,
|
ps.PostsVisibility, ps.SavedVisibility, ps.FollowRequestPolicy,
|
||||||
|
ps.DefaultPostVisibility, ps.IsPrivateProfile,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -971,3 +1053,11 @@ func (r *UserRepository) DeleteUser(ctx context.Context, userID uuid.UUID) error
|
||||||
_, err := r.pool.Exec(ctx, query, userID)
|
_, err := r.pool.Exec(ctx, query, userID)
|
||||||
return err
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
79
go-backend/internal/services/moderation_service.go
Normal file
79
go-backend/internal/services/moderation_service.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -2,9 +2,11 @@ package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/patbritton/sojorn-backend/internal/models"
|
||||||
"github.com/patbritton/sojorn-backend/internal/repository"
|
"github.com/patbritton/sojorn-backend/internal/repository"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
@ -12,105 +14,446 @@ import (
|
||||||
type NotificationService struct {
|
type NotificationService struct {
|
||||||
notifRepo *repository.NotificationRepository
|
notifRepo *repository.NotificationRepository
|
||||||
pushSvc *PushService
|
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{
|
return &NotificationService{
|
||||||
notifRepo: notifRepo,
|
notifRepo: notifRepo,
|
||||||
pushSvc: pushSvc,
|
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
|
// High-Level Notification Methods (Called by Handlers)
|
||||||
// Validate UUIDs (for future use when we fix notification storage)
|
// ============================================================================
|
||||||
_, err := uuid.Parse(userID)
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid user ID: %w", err)
|
log.Warn().Err(err).Str("user_id", userID.String()).Msg("Failed to send mention notification")
|
||||||
}
|
|
||||||
|
|
||||||
_, err = uuid.Parse(actorID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid actor ID: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *NotificationService) buildPushNotification(notificationType string, metadata map[string]interface{}) (title, body string, data map[string]string) {
|
// NotifyFollow sends a notification when someone follows a user
|
||||||
actorName, _ := metadata["actor_name"].(string)
|
func (s *NotificationService) NotifyFollow(ctx context.Context, followedUserID, followerID string, isPending bool) error {
|
||||||
|
notifType := models.NotificationTypeFollow
|
||||||
|
if isPending {
|
||||||
|
notifType = models.NotificationTypeFollowRequest
|
||||||
|
}
|
||||||
|
|
||||||
switch notificationType {
|
return s.sendNotification(ctx, models.PushNotificationRequest{
|
||||||
case "beacon_vouch":
|
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"
|
title = "Beacon Vouched"
|
||||||
body = fmt.Sprintf("%s vouched for your beacon", actorName)
|
body = fmt.Sprintf("%s vouched for your beacon", actorName)
|
||||||
data = map[string]string{
|
data["beacon_id"] = req.PostID.String()
|
||||||
"type": "beacon_vouch",
|
|
||||||
"beacon_id": getString(metadata, "beacon_id"),
|
case models.NotificationTypeBeaconReport:
|
||||||
"target": "beacon_map", // Deep link to map
|
|
||||||
}
|
|
||||||
case "beacon_report":
|
|
||||||
title = "Beacon Reported"
|
title = "Beacon Reported"
|
||||||
body = fmt.Sprintf("%s reported your beacon", actorName)
|
body = fmt.Sprintf("%s reported your beacon", actorName)
|
||||||
data = map[string]string{
|
data["beacon_id"] = req.PostID.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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
title = "Sojorn"
|
title = "Sojorn"
|
||||||
body = "You have a new notification"
|
body = "You have a new notification"
|
||||||
data = map[string]string{"type": notificationType}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return title, body, data
|
return title, body, data
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper functions
|
func (s *NotificationService) getNavigationTarget(notifType, postType string) string {
|
||||||
func parseNullableUUID(s *string) *uuid.UUID {
|
switch notifType {
|
||||||
if s == nil {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
u, err := uuid.Parse(*s)
|
u, err := uuid.Parse(s)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return &u
|
return &u
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ptrString(s string) *string {
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
func getString(m map[string]interface{}, key string) string {
|
func getString(m map[string]interface{}, key string) string {
|
||||||
if val, ok := m[key]; ok {
|
if val, ok := m[key]; ok {
|
||||||
if str, ok := val.(string); ok {
|
if str, ok := val.(string); ok {
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,10 @@ func NewPushService(userRepo *repository.UserRepository, credentialsFile string)
|
||||||
opt = option.WithCredentialsFile(credentialsFile)
|
opt = option.WithCredentialsFile(credentialsFile)
|
||||||
} else {
|
} else {
|
||||||
log.Warn().Msg("Firebase credentials file not found, using default credentials")
|
log.Warn().Msg("Firebase credentials file not found, using default credentials")
|
||||||
opt = option.WithoutAuthentication() // Or handle differently
|
opt = option.WithoutAuthentication()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Attempt to use logic suitable for Cloud Run/GCP or emulator
|
opt = option.WithCredentialsFile("firebase-service-account.json")
|
||||||
opt = option.WithCredentialsFile("firebase-service-account.json") // Default fallback
|
|
||||||
}
|
}
|
||||||
|
|
||||||
app, err := firebase.NewApp(ctx, nil, opt)
|
app, err := firebase.NewApp(ctx, nil, opt)
|
||||||
|
|
@ -51,17 +50,24 @@ func NewPushService(userRepo *repository.UserRepository, credentialsFile string)
|
||||||
}, nil
|
}, 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 {
|
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)
|
tokens, err := s.userRepo.GetFCMTokens(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get FCM tokens: %w", err)
|
return fmt.Errorf("failed to get FCM tokens: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(tokens) == 0 {
|
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{
|
message := &messaging.MulticastMessage{
|
||||||
Tokens: tokens,
|
Tokens: tokens,
|
||||||
Notification: &messaging.Notification{
|
Notification: &messaging.Notification{
|
||||||
|
|
@ -74,8 +80,69 @@ func (s *PushService) SendPush(ctx context.Context, userID, title, body string,
|
||||||
Notification: &messaging.AndroidNotification{
|
Notification: &messaging.AndroidNotification{
|
||||||
Sound: "default",
|
Sound: "default",
|
||||||
ClickAction: "FLUTTER_NOTIFICATION_CLICK",
|
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{
|
APNS: &messaging.APNSConfig{
|
||||||
Payload: &messaging.APNSPayload{
|
Payload: &messaging.APNSPayload{
|
||||||
Aps: &messaging.Aps{
|
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)
|
_, err := s.client.Send(ctx, message)
|
||||||
if err != nil {
|
return err
|
||||||
return fmt.Errorf("error sending multicast message: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if br.FailureCount > 0 {
|
// SendSilentPush sends a data-only notification for badge updates
|
||||||
var failedTokens []string
|
func (s *PushService) SendSilentPush(ctx context.Context, userID string, data map[string]string, badge int) error {
|
||||||
for idx, resp := range br.Responses {
|
tokens, err := s.userRepo.GetFCMTokens(ctx, userID)
|
||||||
|
if err != nil || len(tokens) == 0 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
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.Success {
|
||||||
if resp.Error != nil && messaging.IsRegistrationTokenNotRegistered(resp.Error) {
|
if resp.Error != nil && messaging.IsRegistrationTokenNotRegistered(resp.Error) {
|
||||||
|
invalidTokens = append(invalidTokens, tokens[idx])
|
||||||
if err := s.userRepo.DeleteFCMToken(ctx, userID, tokens[idx]); err != nil {
|
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")
|
log.Warn().Err(err).Str("user_id", userID).Msg("Failed to delete invalid FCM token")
|
||||||
}
|
}
|
||||||
continue
|
} else if resp.Error != nil {
|
||||||
}
|
log.Warn().
|
||||||
failedTokens = append(failedTokens, tokens[idx])
|
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
5
go-backend/seed_suggested.sql
Normal file
5
go-backend/seed_suggested.sql
Normal 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;
|
||||||
|
|
@ -4,6 +4,8 @@ class ProfilePrivacySettings {
|
||||||
final String postsVisibility;
|
final String postsVisibility;
|
||||||
final String savedVisibility;
|
final String savedVisibility;
|
||||||
final String followRequestPolicy;
|
final String followRequestPolicy;
|
||||||
|
final String defaultPostVisibility;
|
||||||
|
final bool isPrivateProfile;
|
||||||
|
|
||||||
const ProfilePrivacySettings({
|
const ProfilePrivacySettings({
|
||||||
required this.userId,
|
required this.userId,
|
||||||
|
|
@ -11,6 +13,8 @@ class ProfilePrivacySettings {
|
||||||
required this.postsVisibility,
|
required this.postsVisibility,
|
||||||
required this.savedVisibility,
|
required this.savedVisibility,
|
||||||
required this.followRequestPolicy,
|
required this.followRequestPolicy,
|
||||||
|
required this.defaultPostVisibility,
|
||||||
|
required this.isPrivateProfile,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory ProfilePrivacySettings.fromJson(Map<String, dynamic> json) {
|
factory ProfilePrivacySettings.fromJson(Map<String, dynamic> json) {
|
||||||
|
|
@ -20,6 +24,8 @@ class ProfilePrivacySettings {
|
||||||
postsVisibility: json['posts_visibility'] as String? ?? 'public',
|
postsVisibility: json['posts_visibility'] as String? ?? 'public',
|
||||||
savedVisibility: json['saved_visibility'] as String? ?? 'private',
|
savedVisibility: json['saved_visibility'] as String? ?? 'private',
|
||||||
followRequestPolicy: json['follow_request_policy'] as String? ?? 'everyone',
|
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,
|
'posts_visibility': postsVisibility,
|
||||||
'saved_visibility': savedVisibility,
|
'saved_visibility': savedVisibility,
|
||||||
'follow_request_policy': followRequestPolicy,
|
'follow_request_policy': followRequestPolicy,
|
||||||
|
'default_post_visibility': defaultPostVisibility,
|
||||||
|
'is_private_profile': isPrivateProfile,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -38,6 +46,8 @@ class ProfilePrivacySettings {
|
||||||
String? postsVisibility,
|
String? postsVisibility,
|
||||||
String? savedVisibility,
|
String? savedVisibility,
|
||||||
String? followRequestPolicy,
|
String? followRequestPolicy,
|
||||||
|
String? defaultPostVisibility,
|
||||||
|
bool? isPrivateProfile,
|
||||||
}) {
|
}) {
|
||||||
return ProfilePrivacySettings(
|
return ProfilePrivacySettings(
|
||||||
userId: userId,
|
userId: userId,
|
||||||
|
|
@ -45,6 +55,8 @@ class ProfilePrivacySettings {
|
||||||
postsVisibility: postsVisibility ?? this.postsVisibility,
|
postsVisibility: postsVisibility ?? this.postsVisibility,
|
||||||
savedVisibility: savedVisibility ?? this.savedVisibility,
|
savedVisibility: savedVisibility ?? this.savedVisibility,
|
||||||
followRequestPolicy: followRequestPolicy ?? this.followRequestPolicy,
|
followRequestPolicy: followRequestPolicy ?? this.followRequestPolicy,
|
||||||
|
defaultPostVisibility: defaultPostVisibility ?? this.defaultPostVisibility,
|
||||||
|
isPrivateProfile: isPrivateProfile ?? this.isPrivateProfile,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,6 +67,8 @@ class ProfilePrivacySettings {
|
||||||
postsVisibility: 'public',
|
postsVisibility: 'public',
|
||||||
savedVisibility: 'private',
|
savedVisibility: 'private',
|
||||||
followRequestPolicy: 'everyone',
|
followRequestPolicy: 'everyone',
|
||||||
|
defaultPostVisibility: 'public',
|
||||||
|
isPrivateProfile: false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,39 @@
|
||||||
class UserSettings {
|
class UserSettings {
|
||||||
final String userId;
|
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;
|
final int? defaultPostTtl;
|
||||||
|
|
||||||
const UserSettings({
|
const UserSettings({
|
||||||
required this.userId,
|
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,
|
this.defaultPostTtl,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory UserSettings.fromJson(Map<String, dynamic> json) {
|
factory UserSettings.fromJson(Map<String, dynamic> json) {
|
||||||
return UserSettings(
|
return UserSettings(
|
||||||
userId: json['user_id'] as String,
|
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']),
|
defaultPostTtl: _parseIntervalHours(json['default_post_ttl']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -17,15 +41,39 @@ class UserSettings {
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
return {
|
return {
|
||||||
'user_id': userId,
|
'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({
|
UserSettings copyWith({
|
||||||
|
String? theme,
|
||||||
|
String? language,
|
||||||
|
bool? notificationsEnabled,
|
||||||
|
bool? emailNotifications,
|
||||||
|
bool? pushNotifications,
|
||||||
|
String? contentFilterLevel,
|
||||||
|
bool? autoPlayVideos,
|
||||||
|
bool? dataSaverMode,
|
||||||
int? defaultPostTtl,
|
int? defaultPostTtl,
|
||||||
}) {
|
}) {
|
||||||
return UserSettings(
|
return UserSettings(
|
||||||
userId: userId,
|
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,
|
defaultPostTtl: defaultPostTtl ?? this.defaultPostTtl,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -38,9 +86,9 @@ class UserSettings {
|
||||||
final trimmed = value.trim();
|
final trimmed = value.trim();
|
||||||
if (trimmed.isEmpty) return null;
|
if (trimmed.isEmpty) return null;
|
||||||
|
|
||||||
final dayMatch = RegExp(r'(\\d+)\\s+day').firstMatch(trimmed);
|
final dayMatch = RegExp(r'(\d+)\s+day').firstMatch(trimmed);
|
||||||
final hourMatch = RegExp(r'(\\d+)\\s+hour').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 timeMatch = RegExp(r'(\d{1,2}):(\d{2}):(\d{2})').firstMatch(trimmed);
|
||||||
|
|
||||||
var totalHours = 0;
|
var totalHours = 0;
|
||||||
if (dayMatch != null) {
|
if (dayMatch != null) {
|
||||||
|
|
|
||||||
121
sojorn_app/lib/providers/settings_provider.dart
Normal file
121
sojorn_app/lib/providers/settings_provider.dart
Normal 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);
|
||||||
|
});
|
||||||
39
sojorn_app/lib/providers/upload_provider.dart
Normal file
39
sojorn_app/lib/providers/upload_provider.dart
Normal 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();
|
||||||
|
});
|
||||||
|
|
@ -15,7 +15,9 @@ import '../screens/quips/create/quip_creation_flow.dart';
|
||||||
import '../screens/quips/feed/quips_feed_screen.dart';
|
import '../screens/quips/feed/quips_feed_screen.dart';
|
||||||
import '../screens/profile/profile_screen.dart';
|
import '../screens/profile/profile_screen.dart';
|
||||||
import '../screens/profile/viewable_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/auth/auth_gate.dart';
|
||||||
|
import '../screens/discover/discover_screen.dart';
|
||||||
import '../screens/secure_chat/secure_chat_full_screen.dart';
|
import '../screens/secure_chat/secure_chat_full_screen.dart';
|
||||||
|
|
||||||
/// App routing config (GoRouter).
|
/// App routing config (GoRouter).
|
||||||
|
|
@ -79,19 +81,8 @@ class AppRoutes {
|
||||||
StatefulShellBranch(
|
StatefulShellBranch(
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: beaconPrefix,
|
path: '/discover',
|
||||||
builder: (_, state) {
|
builder: (_, __) => const DiscoverScreen(),
|
||||||
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();
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -108,6 +99,12 @@ class AppRoutes {
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: profile,
|
path: profile,
|
||||||
builder: (_, __) => const ProfileScreen(),
|
builder: (_, __) => const ProfileScreen(),
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: 'blocked',
|
||||||
|
builder: (_, __) => const BlockedUsersScreen(),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
743
sojorn_app/lib/screens/discover/discover_screen.dart
Normal file
743
sojorn_app/lib/screens/discover/discover_screen.dart
Normal 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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,10 +6,13 @@ import '../../theme/app_theme.dart';
|
||||||
import '../notifications/notifications_screen.dart';
|
import '../notifications/notifications_screen.dart';
|
||||||
import '../compose/compose_screen.dart';
|
import '../compose/compose_screen.dart';
|
||||||
import '../search/search_screen.dart';
|
import '../search/search_screen.dart';
|
||||||
|
import '../discover/discover_screen.dart';
|
||||||
import '../beacon/beacon_screen.dart';
|
import '../beacon/beacon_screen.dart';
|
||||||
import '../quips/create/quip_creation_flow.dart';
|
import '../quips/create/quip_creation_flow.dart';
|
||||||
import '../secure_chat/secure_chat_full_screen.dart';
|
import '../secure_chat/secure_chat_full_screen.dart';
|
||||||
import '../../widgets/radial_menu_overlay.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
|
/// Root shell for the main tabs. The active tab is controlled by GoRouter's
|
||||||
/// [StatefulNavigationShell] so navigation state and tab selection stay in sync.
|
/// [StatefulNavigationShell] so navigation state and tab selection stay in sync.
|
||||||
|
|
@ -92,7 +95,33 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
|
||||||
offset: const Offset(0, 12),
|
offset: const Offset(0, 12),
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () => setState(() => _isRadialMenuVisible = !_isRadialMenuVisible),
|
onTap: () => setState(() => _isRadialMenuVisible = !_isRadialMenuVisible),
|
||||||
child: Container(
|
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,
|
width: 56,
|
||||||
height: 56,
|
height: 56,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
@ -112,6 +141,8 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
|
||||||
size: 32,
|
size: 32,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
|
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
|
||||||
|
|
@ -169,11 +200,14 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
|
||||||
|
|
||||||
PreferredSizeWidget _buildAppBar() {
|
PreferredSizeWidget _buildAppBar() {
|
||||||
return AppBar(
|
return AppBar(
|
||||||
title: Image.asset(
|
title: InkWell(
|
||||||
|
onTap: () => widget.navigationShell.goBranch(0),
|
||||||
|
child: Image.asset(
|
||||||
'assets/images/toplogo.png',
|
'assets/images/toplogo.png',
|
||||||
height: 38,
|
height: 38,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
backgroundColor: AppTheme.scaffoldBg,
|
backgroundColor: AppTheme.scaffoldBg,
|
||||||
|
|
@ -186,11 +220,9 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(Icons.search, color: AppTheme.navyBlue),
|
icon: Icon(Icons.search, color: AppTheme.navyBlue),
|
||||||
tooltip: 'Search',
|
tooltip: 'Discover',
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.of(context).push(
|
widget.navigationShell.goBranch(1);
|
||||||
MaterialPageRoute(builder: (_) => const SearchScreen()),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|
|
||||||
216
sojorn_app/lib/screens/profile/blocked_users_screen.dart
Normal file
216
sojorn_app/lib/screens/profile/blocked_users_screen.dart
Normal 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'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
97
sojorn_app/lib/screens/profile/category_settings_screen.dart
Normal file
97
sojorn_app/lib/screens/profile/category_settings_screen.dart
Normal 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
|
|
@ -31,14 +31,19 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||||
SearchResults? results;
|
SearchResults? results;
|
||||||
List<RecentSearch> recentSearches = [];
|
List<RecentSearch> recentSearches = [];
|
||||||
int _searchEpoch = 0;
|
int _searchEpoch = 0;
|
||||||
|
|
||||||
final Map<String, Future<Post>> _postFutures = {};
|
final Map<String, Future<Post>> _postFutures = {};
|
||||||
|
|
||||||
|
// Discovery State
|
||||||
|
bool _isDiscoveryLoading = false;
|
||||||
|
List<Post> _discoveryPosts = [];
|
||||||
|
|
||||||
static const Duration debounceDuration = Duration(milliseconds: 250);
|
static const Duration debounceDuration = Duration(milliseconds: 250);
|
||||||
static const List<String> trendingTags = [
|
static const List<String> trendingTags = [
|
||||||
'safety',
|
'safety',
|
||||||
'wellness',
|
'wellness',
|
||||||
'growth',
|
'growth',
|
||||||
'mindfulness',
|
'focus',
|
||||||
'community',
|
'community',
|
||||||
'insights',
|
'insights',
|
||||||
'reflection',
|
'reflection',
|
||||||
|
|
@ -62,6 +67,25 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||||
focusNode.requestFocus();
|
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
|
@override
|
||||||
|
|
@ -300,7 +324,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||||
if (isLoading) return buildLoadingState();
|
if (isLoading) return buildLoadingState();
|
||||||
if (hasSearched && results != null) return buildResultsState();
|
if (hasSearched && results != null) return buildResultsState();
|
||||||
if (recentSearches.isNotEmpty) return buildRecentSearchesState();
|
if (recentSearches.isNotEmpty) return buildRecentSearchesState();
|
||||||
return buildTrendingState();
|
return buildDiscoveryState();
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget buildLoadingState() {
|
Widget buildLoadingState() {
|
||||||
|
|
@ -380,6 +404,20 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
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) ...[
|
if (results!.tags.isNotEmpty) ...[
|
||||||
buildSectionHeader(isTagSearch ? 'Tag' : 'Tags'),
|
buildSectionHeader(isTagSearch ? 'Tag' : 'Tags'),
|
||||||
ListView.builder(
|
ListView.builder(
|
||||||
|
|
@ -407,19 +445,6 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
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() {
|
Widget buildDiscoveryState() {
|
||||||
return Column(
|
return SingleChildScrollView(
|
||||||
|
physics: const BouncingScrollPhysics(),
|
||||||
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||||
child: Text('Trending Tags',
|
child: Text('Top Trending',
|
||||||
style: AppTheme.labelMedium.copyWith(color: AppTheme.navyBlue)),
|
style: AppTheme.labelMedium.copyWith(color: AppTheme.navyBlue)),
|
||||||
),
|
),
|
||||||
Expanded(
|
GridView.builder(
|
||||||
child: GridView.builder(
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: 2,
|
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),
|
||||||
],
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -170,6 +170,12 @@ class ApiService {
|
||||||
return [];
|
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 {
|
Future<void> resendVerificationEmail(String email) async {
|
||||||
await _callGoApi('/auth/resend-verification',
|
await _callGoApi('/auth/resend-verification',
|
||||||
method: 'POST', body: {'email': email});
|
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
|
// Social Actions
|
||||||
|
|
@ -1030,7 +1015,7 @@ class ApiService {
|
||||||
return posts.map((p) => Post.fromJson(p)).toList();
|
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);
|
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
|
// Key Backup & Recovery
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:flutter/material.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 '../config/firebase_web_config.dart';
|
||||||
import '../routes/app_routes.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 '../screens/secure_chat/secure_chat_screen.dart';
|
||||||
import 'api_service.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 {
|
class NotificationService {
|
||||||
NotificationService._internal();
|
NotificationService._internal();
|
||||||
|
|
||||||
|
|
@ -19,6 +162,19 @@ class NotificationService {
|
||||||
String? _currentToken;
|
String? _currentToken;
|
||||||
String? _cachedVapidKey;
|
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 {
|
Future<void> init() async {
|
||||||
if (_initialized) return;
|
if (_initialized) return;
|
||||||
_initialized = true;
|
_initialized = true;
|
||||||
|
|
@ -26,6 +182,15 @@ class NotificationService {
|
||||||
try {
|
try {
|
||||||
debugPrint('[FCM] Initializing for platform: ${_resolveDeviceType()}');
|
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(
|
final settings = await _messaging.requestPermission(
|
||||||
alert: true,
|
alert: true,
|
||||||
badge: true,
|
badge: true,
|
||||||
|
|
@ -52,31 +217,41 @@ class NotificationService {
|
||||||
|
|
||||||
if (token != null) {
|
if (token != null) {
|
||||||
_currentToken = token;
|
_currentToken = token;
|
||||||
debugPrint('[FCM] Token registered (${_resolveDeviceType()}): $token');
|
debugPrint('[FCM] Token registered (${_resolveDeviceType()}): ${token.substring(0, 20)}...');
|
||||||
await _upsertToken(token);
|
await _upsertToken(token);
|
||||||
} else {
|
} else {
|
||||||
debugPrint('[FCM] WARNING: Token is null after getToken()');
|
debugPrint('[FCM] WARNING: Token is null after getToken()');
|
||||||
}
|
}
|
||||||
|
|
||||||
_messaging.onTokenRefresh.listen((newToken) {
|
_messaging.onTokenRefresh.listen((newToken) {
|
||||||
debugPrint('[FCM] Token refreshed: $newToken');
|
debugPrint('[FCM] Token refreshed');
|
||||||
_currentToken = newToken;
|
_currentToken = newToken;
|
||||||
_upsertToken(newToken);
|
_upsertToken(newToken);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle messages when app is opened from notification
|
||||||
FirebaseMessaging.onMessageOpenedApp.listen(_handleMessageOpen);
|
FirebaseMessaging.onMessageOpenedApp.listen(_handleMessageOpen);
|
||||||
|
|
||||||
|
// Handle foreground messages - show in-app banner
|
||||||
FirebaseMessaging.onMessage.listen((message) {
|
FirebaseMessaging.onMessage.listen((message) {
|
||||||
debugPrint('[FCM] Foreground message received: ${message.messageId}');
|
debugPrint('[FCM] Foreground message received: ${message.notification?.title}');
|
||||||
debugPrint('[FCM] Message data: ${message.data}');
|
_foregroundMessageController.add(message);
|
||||||
debugPrint('[FCM] Notification: ${message.notification?.title}');
|
_refreshBadgeCount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check for initial message (app opened from terminated state)
|
||||||
final initialMessage = await _messaging.getInitialMessage();
|
final initialMessage = await _messaging.getInitialMessage();
|
||||||
if (initialMessage != null) {
|
if (initialMessage != null) {
|
||||||
debugPrint('[FCM] App opened from notification: ${initialMessage.messageId}');
|
debugPrint('[FCM] App opened from notification');
|
||||||
await _handleMessageOpen(initialMessage);
|
// Delay to allow navigation setup
|
||||||
|
Future.delayed(const Duration(milliseconds: 500), () {
|
||||||
|
_handleMessageOpen(initialMessage);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initial badge count fetch
|
||||||
|
await _refreshBadgeCount();
|
||||||
|
|
||||||
debugPrint('[FCM] Initialization complete');
|
debugPrint('[FCM] Initialization complete');
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
debugPrint('[FCM] Failed to initialize notifications: $e');
|
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)
|
/// Remove the current device's FCM token (call on logout)
|
||||||
Future<void> removeToken() async {
|
Future<void> removeToken() async {
|
||||||
if (_currentToken == null) return;
|
if (_currentToken == null) {
|
||||||
|
debugPrint('[FCM] No token to revoke');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
debugPrint('[FCM] Revoking token...');
|
debugPrint('[FCM] Revoking token from backend...');
|
||||||
await ApiService.instance.callGoApi(
|
await ApiService.instance.callGoApi(
|
||||||
'/notifications/device',
|
'/notifications/device',
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
body: {
|
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) {
|
} catch (e) {
|
||||||
debugPrint('[FCM] Failed to revoke token: $e');
|
debugPrint('[FCM] Failed to revoke token: $e');
|
||||||
} finally {
|
} finally {
|
||||||
_currentToken = null;
|
_currentToken = null;
|
||||||
|
_initialized = false;
|
||||||
|
_currentBadge = UnreadBadge();
|
||||||
|
_badgeController.add(_currentBadge);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -125,18 +328,10 @@ class NotificationService {
|
||||||
|
|
||||||
String _resolveDeviceType() {
|
String _resolveDeviceType() {
|
||||||
if (kIsWeb) return 'web';
|
if (kIsWeb) return 'web';
|
||||||
switch (defaultTargetPlatform) {
|
if (Platform.isAndroid) return 'android';
|
||||||
case TargetPlatform.iOS:
|
if (Platform.isIOS) return 'ios';
|
||||||
return 'ios';
|
|
||||||
case TargetPlatform.android:
|
|
||||||
return 'android';
|
|
||||||
case TargetPlatform.fuchsia:
|
|
||||||
case TargetPlatform.linux:
|
|
||||||
case TargetPlatform.macOS:
|
|
||||||
case TargetPlatform.windows:
|
|
||||||
return 'desktop';
|
return 'desktop';
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Future<String?> _resolveVapidKey() async {
|
Future<String?> _resolveVapidKey() async {
|
||||||
if (_cachedVapidKey != null && _cachedVapidKey!.isNotEmpty) {
|
if (_cachedVapidKey != null && _cachedVapidKey!.isNotEmpty) {
|
||||||
|
|
@ -151,19 +346,203 @@ class NotificationService {
|
||||||
return null;
|
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 {
|
Future<void> _handleMessageOpen(RemoteMessage message) async {
|
||||||
final data = message.data;
|
final data = message.data;
|
||||||
if (data['type'] != 'chat' && data['type'] != 'new_message') return;
|
final type = data['type'] as String?;
|
||||||
final conversationId = data['conversation_id'];
|
|
||||||
if (conversationId == null) return;
|
|
||||||
|
|
||||||
|
debugPrint('[FCM] Handling message open - type: $type, data: $data');
|
||||||
|
|
||||||
|
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());
|
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 {
|
Future<void> _openConversation(String conversationId) async {
|
||||||
final conversation =
|
final conversation =
|
||||||
await SecureChatService.instance.getConversationById(conversationId);
|
await SecureChatService.instance.getConversationById(conversationId);
|
||||||
if (conversation == null) return;
|
if (conversation == null) {
|
||||||
|
debugPrint('[FCM] Conversation not found: $conversationId');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final navigator = AppRoutes.rootNavigatorKey.currentState;
|
final navigator = AppRoutes.rootNavigatorKey.currentState;
|
||||||
if (navigator == null) return;
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,7 @@ class AppTheme {
|
||||||
static TextStyle get bodyLarge => textTheme.bodyLarge!;
|
static TextStyle get bodyLarge => textTheme.bodyLarge!;
|
||||||
static TextStyle get headlineMedium => textTheme.headlineMedium!;
|
static TextStyle get headlineMedium => textTheme.headlineMedium!;
|
||||||
static TextStyle get headlineSmall => textTheme.headlineSmall!;
|
static TextStyle get headlineSmall => textTheme.headlineSmall!;
|
||||||
|
static TextStyle get labelLarge => textTheme.labelLarge!;
|
||||||
static TextStyle get labelMedium => textTheme.labelMedium!;
|
static TextStyle get labelMedium => textTheme.labelMedium!;
|
||||||
static TextStyle get labelSmall => textTheme.labelSmall!;
|
static TextStyle get labelSmall => textTheme.labelSmall!;
|
||||||
|
|
||||||
|
|
|
||||||
320
sojorn_app/lib/widgets/modals/sanctuary_sheet.dart
Normal file
320
sojorn_app/lib/widgets/modals/sanctuary_sheet.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@ import 'package:flutter_markdown/flutter_markdown.dart';
|
||||||
import 'package:markdown/markdown.dart' as md;
|
import 'package:markdown/markdown.dart' as md;
|
||||||
import '../../utils/external_link_controller.dart';
|
import '../../utils/external_link_controller.dart';
|
||||||
import '../../theme/app_theme.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
|
/// Simple widget to limit max lines for any child
|
||||||
class LimitedMaxLinesBox extends StatelessWidget {
|
class LimitedMaxLinesBox extends StatelessWidget {
|
||||||
|
|
@ -162,7 +162,7 @@ class _MarkdownBodyContent extends StatelessWidget {
|
||||||
final tag = href.replaceFirst('hashtag://', '');
|
final tag = href.replaceFirst('hashtag://', '');
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => SearchScreen(initialQuery: '#$tag'),
|
builder: (_) => DiscoverScreen(initialQuery: '#$tag'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
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 {
|
class HashtagBuilder extends MarkdownElementBuilder {
|
||||||
@override
|
@override
|
||||||
Widget? visitElementAfterWithContext(
|
Widget? visitElementAfterWithContext(
|
||||||
|
|
@ -218,7 +218,7 @@ class HashtagBuilder extends MarkdownElementBuilder {
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => SearchScreen(initialQuery: displayText),
|
builder: (_) => DiscoverScreen(initialQuery: displayText),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -241,6 +241,7 @@ class _PostActionsState extends ConsumerState<PostActions> {
|
||||||
),
|
),
|
||||||
style: IconButton.styleFrom(
|
style: IconButton.styleFrom(
|
||||||
backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.08),
|
backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.08),
|
||||||
|
minimumSize: const Size(44, 44),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
|
|
@ -255,6 +256,7 @@ class _PostActionsState extends ConsumerState<PostActions> {
|
||||||
),
|
),
|
||||||
style: IconButton.styleFrom(
|
style: IconButton.styleFrom(
|
||||||
backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.08),
|
backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.08),
|
||||||
|
minimumSize: const Size(44, 44),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
|
|
@ -263,10 +265,8 @@ class _PostActionsState extends ConsumerState<PostActions> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
// Right side: Reply and Reactions
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
// Single Authority: ReactionsDisplay in compact mode for the actions row
|
|
||||||
ReactionsDisplay(
|
ReactionsDisplay(
|
||||||
reactionCounts: _reactionCounts,
|
reactionCounts: _reactionCounts,
|
||||||
myReactions: _myReactions,
|
myReactions: _myReactions,
|
||||||
|
|
@ -283,7 +283,8 @@ class _PostActionsState extends ConsumerState<PostActions> {
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppTheme.brightNavy,
|
backgroundColor: AppTheme.brightNavy,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16),
|
minimumSize: const Size(0, 44),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:timeago/timeago.dart' as timeago;
|
import 'package:timeago/timeago.dart' as timeago;
|
||||||
import '../../models/post.dart';
|
import '../models/post.dart';
|
||||||
import '../../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
import 'media/signed_media_image.dart';
|
import 'media/signed_media_image.dart';
|
||||||
import 'video_thumbnail_widget.dart';
|
import 'video_thumbnail_widget.dart';
|
||||||
import 'post/post_actions.dart';
|
import 'post/post_actions.dart';
|
||||||
|
|
@ -107,6 +107,7 @@ class PostWithVideoWidget extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -125,15 +125,6 @@ class _RadialMenuOverlayState extends State<RadialMenuOverlay>
|
||||||
},
|
},
|
||||||
angle: startAngle,
|
angle: startAngle,
|
||||||
),
|
),
|
||||||
_MenuItem(
|
|
||||||
icon: Icons.videocam_outlined,
|
|
||||||
label: 'Quip',
|
|
||||||
onTap: () {
|
|
||||||
widget.onDismiss();
|
|
||||||
widget.onQuipTap();
|
|
||||||
},
|
|
||||||
angle: (startAngle + endAngle) / 2, // Middle (top)
|
|
||||||
),
|
|
||||||
_MenuItem(
|
_MenuItem(
|
||||||
icon: Icons.location_on_outlined,
|
icon: Icons.location_on_outlined,
|
||||||
label: 'Beacon',
|
label: 'Beacon',
|
||||||
|
|
@ -141,6 +132,15 @@ class _RadialMenuOverlayState extends State<RadialMenuOverlay>
|
||||||
widget.onDismiss();
|
widget.onDismiss();
|
||||||
widget.onBeaconTap();
|
widget.onBeaconTap();
|
||||||
},
|
},
|
||||||
|
angle: (startAngle + endAngle) / 2, // Middle (top)
|
||||||
|
),
|
||||||
|
_MenuItem(
|
||||||
|
icon: Icons.videocam_outlined,
|
||||||
|
label: 'Quip',
|
||||||
|
onTap: () {
|
||||||
|
widget.onDismiss();
|
||||||
|
widget.onQuipTap();
|
||||||
|
},
|
||||||
angle: endAngle,
|
angle: endAngle,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -51,10 +51,19 @@ class ReactionsDisplay extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildCompactView() {
|
Widget _buildCompactView() {
|
||||||
if (reactionCounts.isEmpty) {
|
return Row(
|
||||||
return _ReactionAddButton(onTap: onAddReaction ?? () {});
|
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
|
// Priority: User's reaction > Top reaction
|
||||||
String? displayEmoji;
|
String? displayEmoji;
|
||||||
if (myReactions.isNotEmpty) {
|
if (myReactions.isNotEmpty) {
|
||||||
|
|
@ -71,6 +80,7 @@ class ReactionsDisplay extends StatelessWidget {
|
||||||
isSelected: myReactions.contains(displayEmoji),
|
isSelected: myReactions.contains(displayEmoji),
|
||||||
tooltipNames: reactionUsers?[displayEmoji],
|
tooltipNames: reactionUsers?[displayEmoji],
|
||||||
onTap: () => onToggleReaction?.call(displayEmoji!),
|
onTap: () => onToggleReaction?.call(displayEmoji!),
|
||||||
|
onLongPress: onAddReaction,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,6 +102,7 @@ class ReactionsDisplay extends StatelessWidget {
|
||||||
isSelected: myReactions.contains(entry.key),
|
isSelected: myReactions.contains(entry.key),
|
||||||
tooltipNames: reactionUsers?[entry.key],
|
tooltipNames: reactionUsers?[entry.key],
|
||||||
onTap: () => onToggleReaction?.call(entry.key),
|
onTap: () => onToggleReaction?.call(entry.key),
|
||||||
|
onLongPress: onAddReaction,
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
if (onAddReaction != null)
|
if (onAddReaction != null)
|
||||||
|
|
@ -108,12 +119,14 @@ class _ReactionChip extends StatefulWidget {
|
||||||
final bool isSelected;
|
final bool isSelected;
|
||||||
final List<String>? tooltipNames;
|
final List<String>? tooltipNames;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
final VoidCallback? onLongPress;
|
||||||
|
|
||||||
const _ReactionChip({
|
const _ReactionChip({
|
||||||
required this.reactionId,
|
required this.reactionId,
|
||||||
required this.count,
|
required this.count,
|
||||||
required this.isSelected,
|
required this.isSelected,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
|
this.onLongPress,
|
||||||
this.tooltipNames,
|
this.tooltipNames,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -136,9 +149,11 @@ class _ReactionChipState extends State<_ReactionChip> {
|
||||||
|
|
||||||
final chip = GestureDetector(
|
final chip = GestureDetector(
|
||||||
onTap: _handleTap,
|
onTap: _handleTap,
|
||||||
|
onLongPress: widget.onLongPress,
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 150),
|
duration: const Duration(milliseconds: 150),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
height: 44,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isMyReaction
|
color: isMyReaction
|
||||||
? AppTheme.brightNavy.withValues(alpha: 0.15)
|
? AppTheme.brightNavy.withValues(alpha: 0.15)
|
||||||
|
|
@ -201,7 +216,8 @@ class _ReactionAddButton extends StatelessWidget {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
height: 44,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppTheme.navyBlue.withValues(alpha: 0.08),
|
color: AppTheme.navyBlue.withValues(alpha: 0.08),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ import 'post/post_menu.dart';
|
||||||
import 'post/post_view_mode.dart';
|
import 'post/post_view_mode.dart';
|
||||||
import 'chain_quote_widget.dart';
|
import 'chain_quote_widget.dart';
|
||||||
import '../routes/app_routes.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.
|
/// 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(
|
PostMenu(
|
||||||
post: post,
|
post: post,
|
||||||
onPostDeleted: onPostChanged,
|
onPostDeleted: onPostChanged,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
import '../utils/link_handler.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.
|
/// 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
|
// Navigate to search with hashtag query
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => SearchScreen(initialQuery: matchText),
|
builder: (_) => DiscoverScreen(initialQuery: matchText),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import '../../models/post.dart';
|
import '../models/post.dart';
|
||||||
import '../../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
import '../media/signed_media_image.dart';
|
import 'media/signed_media_image.dart';
|
||||||
|
|
||||||
/// Widget for displaying video thumbnails on regular posts (Twitter-style)
|
/// Widget for displaying video thumbnails on regular posts (Twitter-style)
|
||||||
/// Clicking opens the Quips feed with the full video
|
/// Clicking opens the Quips feed with the full video
|
||||||
|
|
|
||||||
221
sojorn_docs/features/fcm-implementation.md
Normal file
221
sojorn_docs/features/fcm-implementation.md
Normal 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
|
||||||
|
|
@ -1,33 +1,111 @@
|
||||||
# Notifications Troubleshooting and Fix
|
# Notifications Troubleshooting (Go Backend)
|
||||||
|
|
||||||
## Symptoms
|
> **Note**: This document has been updated for the 100% Go backend migration. Legacy Supabase edge function references are no longer applicable.
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Root Cause
|
## Current Architecture
|
||||||
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.
|
|
||||||
|
|
||||||
## Fix
|
All notification APIs now use the Go backend with JWT authentication:
|
||||||
Explicitly read the `Authorization` header and pass the token to
|
- **Register Token**: `POST /api/v1/notifications/device`
|
||||||
`supabaseClient.auth.getUser(token)`. This ensures the function authenticates the
|
- **Unregister Token**: `DELETE /api/v1/notifications/device`
|
||||||
user consistently even if the SDK does not automatically inject the header.
|
- **Get Notifications**: `GET /api/v1/notifications`
|
||||||
|
|
||||||
## Code Change
|
Authentication uses `Authorization: Bearer <token>` header with Go-issued JWTs.
|
||||||
File: `supabase/functions/notifications/index.ts`
|
|
||||||
|
|
||||||
Key update:
|
---
|
||||||
- Parse `Authorization` header.
|
|
||||||
- Extract bearer token.
|
|
||||||
- Call `getUser(token)` instead of `getUser()` without arguments.
|
|
||||||
|
|
||||||
## Deployment Step
|
## Common Issues
|
||||||
Redeploy the `notifications` edge function so the new auth flow is used.
|
|
||||||
|
|
||||||
## Verification
|
### 1. Token Not Syncing to Backend
|
||||||
- Open the notifications screen.
|
|
||||||
- Confirm the request returns 200 and notifications render.
|
**Symptoms:**
|
||||||
- If it still fails, check edge function logs for missing or empty auth headers.
|
- 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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue