diff --git a/.easignore b/.easignore new file mode 100644 index 0000000..a879885 --- /dev/null +++ b/.easignore @@ -0,0 +1 @@ +backend/nginx/certs/** diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1b914a3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ + +# Expo +.expo/ + +# Local env files +.env +.env.* +*.env + +# Secrets folder (recommended place for service keys) +secrets/ + +# Service account and credential files +*service-account*.json +devbits-*.json +devbits-487803-*.json + +# Private keys and certs +*.key +*.pem +*.p12 +*.jks +*.p8 +*.keystore + +# OS / editor files +.DS_Store +.vscode/ + +# Build artifacts +dist/ +build/ diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/DevBits.iml b/.idea/DevBits.iml new file mode 100644 index 0000000..d6ebd48 --- /dev/null +++ b/.idea/DevBits.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/caches/deviceStreaming.xml b/.idea/caches/deviceStreaming.xml new file mode 100644 index 0000000..8d2a5ae --- /dev/null +++ b/.idea/caches/deviceStreaming.xml @@ -0,0 +1,1454 @@ + + + + + + \ No newline at end of file diff --git a/.idea/markdown.xml b/.idea/markdown.xml new file mode 100644 index 0000000..c61ea33 --- /dev/null +++ b/.idea/markdown.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..c08a2df --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..33742a2 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..d6b7432 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "typescript.tsserver.exclude": ["**/node_modules/**"], + "javascript.tsserver.exclude": ["**/node_modules/**"] +} diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md new file mode 100644 index 0000000..ed48e1d --- /dev/null +++ b/INSTRUCTIONS.md @@ -0,0 +1,116 @@ +# DevBits Application Instructions + +This document provides the essential commands for managing the backend services with Docker and for building and deploying the frontend application. + +## Backend Management (Docker) + +Use separate command sets for each environment. + +### Local DB + Backend (your dev machine) + +Run from project root (`c:\Users\eligf\DevBits`): + +```bash +docker compose -f backend/docker-compose.yml up -d +``` + +Rebuild local backend image: + +```bash +docker compose -f backend/docker-compose.yml up -d --build +``` + +Stop local stack: + +```bash +docker compose -f backend/docker-compose.yml down +``` + +Restart local stack: + +```bash +docker compose -f backend/docker-compose.yml restart +``` + +View local logs: + +```bash +docker compose -f backend/docker-compose.yml logs -f backend +docker compose -f backend/docker-compose.yml logs -f db +docker compose -f backend/docker-compose.yml logs -f nginx +``` + +### Live/Deployed DB + Backend (your server) + +Run these only on your deployed host where DevBits is installed. + +Create backend environment file once (required): + +```bash +cd /path/to/DevBits/backend +cp .env.example .env +# edit .env and set a strong POSTGRES_PASSWORD before first deploy +``` + +```bash +cd /path/to/DevBits/backend +docker compose up -d +docker compose logs -f db +``` + +Rebuild and restart deployment containers: + +```bash +cd /path/to/DevBits/backend +docker compose up -d --build +``` + +Stop deployment stack: + +```bash +cd /path/to/DevBits/backend +docker compose down +``` + +Important safety note: + +- Deployment reset/restore scripts in `backend/scripts` are destructive and should only be run in the environment you intend to modify. +- Never run reset commands against live DB unless you explicitly want a full wipe. + +### Deployment DB scripts + +All deployment database script usage is documented in: + +- `backend/scripts/README.md` + +## Frontend Management (EAS) + +All frontend commands should be run from the `frontend` directory (`c:\Users\eligf\DevBits\frontend`). + +### Install Dependencies + +If you haven't already, or if you've pulled new changes, install the necessary Node.js packages: + +```bash +npm install +``` + +### Build the Android App + +To create a production build of the Android application for the Google Play Store: + +```bash +npx eas build -p android --profile production +``` + +This will generate an `.aab` file and upload it to your Expo account. + +### Submit to Google Play Store + +After a successful build, you can submit the latest build to the Google Play Store for internal testing: + +```bash +npx eas submit -p android --latest --profile production +``` + +This command will automatically find the latest build, download it, and upload it to the Google Play Console. diff --git a/README.md b/README.md index e35b518..39b378e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# DevBits +# DevBits ## Goal: Create an X and LinkedIn crossover for posting real content about your projects, semi-formally @@ -93,7 +93,7 @@ cd /path/to/DevBits/frontend #### 2. Check required packages are installed ```bash -npm install +npm install ``` #### 3. Start the app @@ -124,3 +124,17 @@ cd /path/to/DevBits/frontend ```bash npm run all ``` + +#### Windows quick start (root script) + +From the project root, you can launch backend + frontend together: + +```powershell +./run-all.ps1 +``` + +## Deployment DB scripts + +All deployment database script commands and usage are documented in: + +- [backend/scripts/README.md](backend/scripts/README.md) diff --git a/StartupWeekend.md b/StartupWeekend.md index 3384ad2..a15a99c 100644 --- a/StartupWeekend.md +++ b/StartupWeekend.md @@ -1,9 +1,25 @@ # Goals + - Platform for develpors and people in tech -- Available on phone/ios for now but eventually on most platforms +- Available on phone/ios for now but eventually on most platforms - [ ] basic front-end development - [ ] how to build react native app - [ ] options for deploying database/back-end - [ ] Integrating front-end and back-end - [ ] learn how to test the front-end + +## App TODOs (Current) + +- [ ] Show markdown preview for stream body +- [ ] Show markdown preview for byte body +- [ ] Render profile bio markdown consistently +- [ ] Render comment markdown consistently +- [ ] Use same markdown preview approach as stream subject +- [ ] Fix markdown loading jitter/glitching on heavy stream detail content +- [ ] Fix home screen theme color issues (titles and related UI) +- [ ] Ensure subject in manage streams renders same as other markdown-rendered subject fields +- [ ] In manage streams, always show stream cards normally and open editing in a separate clean popout page +- [ ] From stream detail page, tapping edit should open the same clean popout editor (not inline manage view) +- [ ] Remove all users and completely reset database before deployment +- [ ] Ensure no dummy data or test accounts are created for deployment (blank slate) diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..0282a12 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,3 @@ +nginx/ +.git/ +.vscode/ diff --git a/backend/.gitignore b/backend/.gitignore index bb5e2c7..f050d0e 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -8,3 +8,18 @@ *.dylib *.test *.out + +# Secrets and certificates +*.key +*.pem +*.p12 +*.jks +*.keystore +*.crt + +# Service account files +*service-account*.json +devbits-*.json + +# nginx certs (if you keep private certs locally, move to secrets/ and ignore) +nginx/certs/ diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..78fd43d --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,44 @@ +# Use the official Golang image to create a build artifact. +# This is known as a multi-stage build. +FROM golang:1.24-alpine as builder + +# Set the Current Working Directory inside the container +WORKDIR /app + +# Copy go mod and sum files +COPY go.mod go.sum ./ + +# Download all dependencies. Dependencies will be cached if the go.mod and go.sum files are not changed +RUN go mod download + +# Copy the source code from the current directory to the Working Directory inside the container +COPY . . + +# Build the Go app +# -o /app/main: output the executable to /app/main +# CGO_ENABLED=0: disable CGO for a statically linked binary +# GOOS=linux: build for Linux +RUN CGO_ENABLED=0 GOOS=linux go build -o /app/main ./api + +# Start a new stage from scratch for a smaller image +FROM alpine:latest + +WORKDIR /root/ + +# Copy the Pre-built binary file from the previous stage +COPY --from=builder /app/main . + +# Copy the database file +COPY --from=builder /app/api/internal/database/dev.sqlite3 ./api/internal/database/ +COPY --from=builder /app/api/internal/database/create_tables.sql ./api/internal/database/ + +# Copy the uploads directory +# Note: This assumes your application needs access to this directory. +# If it's for temporary uploads, you might want to use a Docker volume instead. +COPY --from=builder /app/uploads ./uploads + +# Expose port 8080 to the outside world +EXPOSE 8080 + +# Command to run the executable +CMD ["./main"] diff --git a/backend/api/internal/auth/jwt.go b/backend/api/internal/auth/jwt.go new file mode 100644 index 0000000..1619eb6 --- /dev/null +++ b/backend/api/internal/auth/jwt.go @@ -0,0 +1,60 @@ +package auth + +import ( + "errors" + "os" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +const defaultSecret = "devbits-dev-secret" +const tokenTTL = 7 * 24 * time.Hour + +type Claims struct { + UserID int64 `json:"user_id"` + Username string `json:"username"` + jwt.RegisteredClaims +} + +func getSecret() []byte { + secret := os.Getenv("DEVBITS_JWT_SECRET") + if secret == "" { + secret = defaultSecret + } + return []byte(secret) +} + +func GenerateToken(userID int64, username string) (string, error) { + now := time.Now().UTC() + claims := Claims{ + UserID: userID, + Username: username, + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(tokenTTL)), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(getSecret()) +} + +func ParseToken(rawToken string) (*Claims, error) { + token, err := jwt.ParseWithClaims(rawToken, &Claims{}, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, errors.New("unexpected signing method") + } + return getSecret(), nil + }) + if err != nil { + return nil, err + } + + claims, ok := token.Claims.(*Claims) + if !ok || !token.Valid { + return nil, errors.New("invalid token") + } + + return claims, nil +} diff --git a/backend/api/internal/database/comment_queries.go b/backend/api/internal/database/comment_queries.go index 7e8f14c..e8ba65c 100644 --- a/backend/api/internal/database/comment_queries.go +++ b/backend/api/internal/database/comment_queries.go @@ -2,31 +2,71 @@ package database import ( "database/sql" + "encoding/json" "fmt" "net/http" "strconv" "time" - - "backend/api/internal/types" ) +// we can implement this type... +type NullableInt64 struct { + sql.NullInt64 +} + +// ...so that we can create custom functions on it +func (n *NullableInt64) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + n.Valid = false + return nil + } + + var number int64 + if err := json.Unmarshal(data, &number); err != nil { + return err + } + + n.Int64 = number + n.Valid = true + return nil +} + +func (n NullableInt64) MarshalJSON() ([]byte, error) { + if !n.Valid { + return []byte("null"), nil + } + return json.Marshal(n.Int64) +} + +type Comment struct { + ID int64 `json:"id"` + User int64 `json:"user" binding:"required"` + Likes int64 `json:"likes"` + ParentComment NullableInt64 `json:"parent_comment" binding:"required"` + CreationDate time.Time `json:"created_on"` + Content string `json:"content" binding:"required"` + Media []string `json:"media"` +} + // QueryComment retrieves a comment by its ID from the database. // // Parameters: // - id: The unique identifier of the comment to query. // // Returns: -// - *types.Comment: The comment details if found. +// - *Comment: The comment details if found. // - error: An error if the query fails. Returns nil for both if no comment exists. -func QueryComment(id int) (*types.Comment, error) { - query := `SELECT id, user_id, content, likes, creation_date, parent_comment_id FROM Comments WHERE id = ?;` +func QueryComment(id int) (*Comment, error) { + query := `SELECT id, user_id, content, COALESCE(media, '[]'), likes, creation_date, parent_comment_id FROM comments WHERE id = $1;` row := DB.QueryRow(query, id) - var comment types.Comment + var comment Comment + var mediaJSON string err := row.Scan( &comment.ID, &comment.User, &comment.Content, + &mediaJSON, &comment.Likes, &comment.CreationDate, &comment.ParentComment, @@ -38,6 +78,10 @@ func QueryComment(id int) (*types.Comment, error) { return nil, err } + if err := UnmarshalFromJSON(mediaJSON, &comment.Media); err != nil { + return nil, err + } + return &comment, nil } @@ -47,20 +91,21 @@ func QueryComment(id int) (*types.Comment, error) { // - id: The unique identifier of the user to query. // // Returns: -// - []types.Post: The post details if found. +// - []Comment: The post details if found. // - error: An error if the query fails. Returns nil for both if no comments exists. -func QueryCommentsByUserId(userId int) ([]types.Comment, int, error) { +func QueryCommentsByUserId(userId int) ([]Comment, int, error) { query := ` - SELECT - c.id AS comment_id, - c.user_id, - c.content, - c.likes, - c.creation_date, - c.parent_comment_id - FROM Comments c - JOIN PostComments pc ON c.id = pc.comment_id - WHERE c.user_id = ?; + SELECT + c.id AS comment_id, + c.user_id, + c.content, + COALESCE(c.media, '[]'), + c.likes, + c.creation_date, + c.parent_comment_id + FROM comments c + JOIN postcomments pc ON c.id = pc.comment_id + WHERE c.user_id = $1; ` postRows, err := DB.Query(query, userId) @@ -70,30 +115,33 @@ func QueryCommentsByUserId(userId int) ([]types.Comment, int, error) { defer postRows.Close() query = ` - SELECT - c.id AS comment_id, - c.user_id, - c.content, - (SELECT COUNT(*) FROM CommentLikes cl WHERE cl.comment_id = c.id) AS likes, - c.creation_date, - c.parent_comment_id - FROM Comments c - JOIN ProjectComments pc ON c.id = pc.comment_id - WHERE pc.project_id = ?; - ` + SELECT + c.id AS comment_id, + c.user_id, + c.content, + COALESCE(c.media, '[]'), + (SELECT COUNT(*) FROM CommentLikes cl WHERE cl.comment_id = c.id) AS likes, + c.creation_date, + c.parent_comment_id + FROM comments c + JOIN projectcomments pc ON c.id = pc.comment_id + WHERE c.user_id = $1; + ` projRows, err := DB.Query(query, userId) if err != nil { return nil, http.StatusNotFound, err } defer projRows.Close() - var comments []types.Comment + comments := []Comment{} for projRows.Next() { - var comment types.Comment + var comment Comment + var mediaJSON string err := projRows.Scan( &comment.ID, &comment.User, &comment.Content, + &mediaJSON, &comment.Likes, &comment.CreationDate, &comment.ParentComment, @@ -105,14 +153,19 @@ func QueryCommentsByUserId(userId int) ([]types.Comment, int, error) { } return nil, http.StatusInternalServerError, err } + if err := UnmarshalFromJSON(mediaJSON, &comment.Media); err != nil { + return nil, http.StatusBadRequest, err + } comments = append(comments, comment) } for postRows.Next() { - var comment types.Comment + var comment Comment + var mediaJSON string err := postRows.Scan( &comment.ID, &comment.User, &comment.Content, + &mediaJSON, &comment.Likes, &comment.CreationDate, &comment.ParentComment, @@ -124,6 +177,9 @@ func QueryCommentsByUserId(userId int) ([]types.Comment, int, error) { } return nil, http.StatusInternalServerError, err } + if err := UnmarshalFromJSON(mediaJSON, &comment.Media); err != nil { + return nil, http.StatusBadRequest, err + } comments = append(comments, comment) } @@ -136,34 +192,37 @@ func QueryCommentsByUserId(userId int) ([]types.Comment, int, error) { // - id: The unique identifier of the project to query. // // Returns: -// - *types.Comment: The comment details if found. +// - *Comment: The comment details if found. // - error: An error if the query fails. Returns nil for both if no comment exists. -func QueryCommentsByProjectId(id int) ([]types.Comment, int, error) { +func QueryCommentsByProjectId(id int) ([]Comment, int, error) { query := ` - SELECT - c.id AS comment_id, - c.user_id, - c.content, - c.likes, - c.creation_date, - c.parent_comment_id - FROM Comments c - JOIN ProjectComments pc ON c.id = pc.comment_id - WHERE pc.project_id = ?; + SELECT + c.id AS comment_id, + c.user_id, + c.content, + COALESCE(c.media, '[]'), + c.likes, + c.creation_date, + c.parent_comment_id + FROM comments c + JOIN projectcomments pc ON c.id = pc.comment_id + WHERE pc.project_id = $1; ` rows, err := DB.Query(query, id) if err != nil { return nil, http.StatusNotFound, err } defer rows.Close() - var comments []types.Comment + comments := []Comment{} for rows.Next() { - var comment types.Comment + var comment Comment + var mediaJSON string err := rows.Scan( &comment.ID, &comment.User, &comment.Content, + &mediaJSON, &comment.Likes, &comment.CreationDate, &comment.ParentComment, @@ -175,6 +234,9 @@ func QueryCommentsByProjectId(id int) ([]types.Comment, int, error) { } return nil, http.StatusInternalServerError, err } + if err := UnmarshalFromJSON(mediaJSON, &comment.Media); err != nil { + return nil, http.StatusBadRequest, err + } comments = append(comments, comment) } return comments, http.StatusOK, nil @@ -186,34 +248,37 @@ func QueryCommentsByProjectId(id int) ([]types.Comment, int, error) { // - id: The unique identifier of the post to query. // // Returns: -// - *types.Comment: The comment details if found. +// - *Comment: The comment details if found. // - error: An error if the query fails. Returns nil for both if no comment exists. -func QueryCommentsByPostId(id int) ([]types.Comment, int, error) { +func QueryCommentsByPostId(id int) ([]Comment, int, error) { query := ` - SELECT - c.id AS comment_id, - c.user_id, - c.content, - c.likes, - c.creation_date, - c.parent_comment_id - FROM Comments c - JOIN PostComments pc ON c.id = pc.comment_id - WHERE pc.post_id = ?; + SELECT + c.id AS comment_id, + c.user_id, + c.content, + COALESCE(c.media, '[]'), + c.likes, + c.creation_date, + c.parent_comment_id + FROM comments c + JOIN postcomments pc ON c.id = pc.comment_id + WHERE pc.post_id = $1; ` rows, err := DB.Query(query, id) if err != nil { return nil, http.StatusNotFound, err } defer rows.Close() - var comments []types.Comment + comments := []Comment{} for rows.Next() { - var comment types.Comment + var comment Comment + var mediaJSON string err := rows.Scan( &comment.ID, &comment.User, &comment.Content, + &mediaJSON, &comment.Likes, &comment.CreationDate, &comment.ParentComment, @@ -225,6 +290,9 @@ func QueryCommentsByPostId(id int) ([]types.Comment, int, error) { } return nil, http.StatusInternalServerError, err } + if err := UnmarshalFromJSON(mediaJSON, &comment.Media); err != nil { + return nil, http.StatusBadRequest, err + } comments = append(comments, comment) } return comments, http.StatusOK, nil @@ -236,33 +304,36 @@ func QueryCommentsByPostId(id int) ([]types.Comment, int, error) { // - id: The unique identifier of the comment to query. // // Returns: -// - *types.Comment: The comment details if found. +// - *Comment: The comment details if found. // - error: An error if the query fails. Returns nil for both if no comment exists. -func QueryCommentsByCommentId(id int) ([]types.Comment, int, error) { +func QueryCommentsByCommentId(id int) ([]Comment, int, error) { query := ` - SELECT - c.id AS comment_id, - c.user_id, - c.content, - c.likes, - c.creation_date, - c.parent_comment_id - FROM Comments c - WHERE c.parent_comment_id = ?; + SELECT + c.id AS comment_id, + c.user_id, + c.content, + COALESCE(c.media, '[]'), + c.likes, + c.creation_date, + c.parent_comment_id + FROM comments c + WHERE c.parent_comment_id = $1; ` rows, err := DB.Query(query, id) if err != nil { return nil, http.StatusNotFound, err } defer rows.Close() - var comments []types.Comment + comments := []Comment{} for rows.Next() { - var comment types.Comment + var comment Comment + var mediaJSON string err := rows.Scan( &comment.ID, &comment.User, &comment.Content, + &mediaJSON, &comment.Likes, &comment.CreationDate, &comment.ParentComment, @@ -274,6 +345,9 @@ func QueryCommentsByCommentId(id int) ([]types.Comment, int, error) { } return nil, http.StatusInternalServerError, err } + if err := UnmarshalFromJSON(mediaJSON, &comment.Media); err != nil { + return nil, http.StatusBadRequest, err + } comments = append(comments, comment) } return comments, http.StatusOK, nil @@ -288,27 +362,48 @@ func QueryCommentsByCommentId(id int) ([]types.Comment, int, error) { // Returns: // - int64: The ID of the newly created comment. // - error: An error if the operation fails. -func QueryCreateCommentOnPost(comment types.Comment, postId int) (int64, error) { - currentTime := time.Now().UTC() - - query := `INSERT INTO Comments (user_id, content, parent_comment_id, likes, creation_date) - VALUES (?, ?, ?, ?, ?);` +func QueryCreateCommentOnPost(comment Comment, postId int) (int64, error) { + tx, err := DB.Begin() + if err != nil { + return -1, fmt.Errorf("failed to begin transaction: %v", err) + } - res, err := DB.Exec(query, comment.User, comment.Content, comment.ParentComment, 0, currentTime) + defer func() { + if err != nil { + tx.Rollback() + } else { + tx.Commit() + } + }() + currentTime := time.Now().UTC() + mediaJSON, err := MarshalToJSON(comment.Media) if err != nil { - return -1, fmt.Errorf("Failed to create comment: %v", err) + return -1, err } - lastId, err := res.LastInsertId() + query := `INSERT INTO comments (user_id, content, media, parent_comment_id, likes, creation_date) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING id;` + + var lastId int64 + err = tx.QueryRow( + query, + comment.User, + comment.Content, + string(mediaJSON), + comment.ParentComment, + 0, + currentTime, + ).Scan(&lastId) + if err != nil { - return -1, fmt.Errorf("Failed to ensure post was created: %v", err) + return -1, fmt.Errorf("Failed to create comment: %v", err) } - query = `INSERT INTO PostComments (user_id, post_id, comment_id) - VALUES (?, ?, ?)` + query = `INSERT INTO postcomments (user_id, post_id, comment_id) + VALUES ($1, $2, $3)` - res, err = DB.Exec(query, comment.User, postId, lastId) + _, err = tx.Exec(query, comment.User, postId, lastId) if err != nil { return -1, fmt.Errorf("Failed to link comment to post: %v", err) } @@ -325,27 +420,47 @@ func QueryCreateCommentOnPost(comment types.Comment, postId int) (int64, error) // Returns: // - int64: The ID of the newly created comment. // - error: An error if the operation fails. -func QueryCreateCommentOnProject(comment types.Comment, projectId int) (int64, error) { +func QueryCreateCommentOnProject(comment Comment, projectId int) (int64, error) { + tx, err := DB.Begin() + if err != nil { + return -1, fmt.Errorf("failed to begin transaction: %v", err) + } + + defer func() { + if err != nil { + tx.Rollback() + } else { + tx.Commit() + } + }() currentTime := time.Now().UTC() + mediaJSON, err := MarshalToJSON(comment.Media) + if err != nil { + return -1, err + } - query := `INSERT INTO Comments (user_id, content, parent_comment_id, likes, creation_date) - VALUES (?, ?, ?, ?, ?);` + query := `INSERT INTO comments (user_id, content, media, parent_comment_id, likes, creation_date) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING id;` - res, err := DB.Exec(query, comment.User, comment.Content, comment.ParentComment, 0, currentTime) + var lastId int64 + err = tx.QueryRow( + query, + comment.User, + comment.Content, + string(mediaJSON), + comment.ParentComment, + 0, + currentTime, + ).Scan(&lastId) if err != nil { return -1, fmt.Errorf("Failed to create comment: %v", err) } - lastId, err := res.LastInsertId() - if err != nil { - return -1, fmt.Errorf("Failed to ensure project was created: %v", err) - } + query = `INSERT INTO projectcomments (user_id, project_id, comment_id) + VALUES ($1, $2, $3)` - query = `INSERT INTO ProjectComments (user_id, project_id, comment_id) - VALUES (?, ?, ?)` - - res, err = DB.Exec(query, comment.User, projectId, lastId) + _, err = tx.Exec(query, comment.User, projectId, lastId) if err != nil { return -1, fmt.Errorf("Failed to link comment to project: %v", err) } @@ -362,23 +477,31 @@ func QueryCreateCommentOnProject(comment types.Comment, projectId int) (int64, e // Returns: // - int64: The ID of the newly created comment. // - error: An error if the operation fails. -func QueryCreateCommentOnComment(comment types.Comment, commentId int) (int64, error) { +func QueryCreateCommentOnComment(comment Comment, commentId int) (int64, error) { currentTime := time.Now().UTC() + mediaJSON, err := MarshalToJSON(comment.Media) + if err != nil { + return -1, err + } - query := `INSERT INTO Comments (user_id, content, parent_comment_id, likes, creation_date) - VALUES (?, ?, ?, ?, ?);` + query := `INSERT INTO comments (user_id, content, media, parent_comment_id, likes, creation_date) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING id;` - res, err := DB.Exec(query, comment.User, comment.Content, commentId, 0, currentTime) + var lastId int64 + err = DB.QueryRow( + query, + comment.User, + comment.Content, + string(mediaJSON), + commentId, + 0, + currentTime, + ).Scan(&lastId) if err != nil { return -1, fmt.Errorf("Failed to create comment: %v", err) } - lastId, err := res.LastInsertId() - if err != nil { - return -1, fmt.Errorf("Failed to ensure comment was created: %v", err) - } - return lastId, nil } @@ -391,18 +514,31 @@ func QueryCreateCommentOnComment(comment types.Comment, commentId int) (int64, e // - int16: http status code // - error: An error if the operation fails. func QueryDeleteComment(id int) (int16, error) { - _, err := DB.Exec(`UPDATE PostComments SET user_id = -1 WHERE comment_id = ?`, id) + tx, err := DB.Begin() + if err != nil { + return -1, fmt.Errorf("failed to begin transaction: %v", err) + } + + defer func() { + if err != nil { + tx.Rollback() + } else { + tx.Commit() + } + }() + + _, err = tx.Exec(`UPDATE postcomments SET user_id = -1 WHERE comment_id = $1`, id) if err != nil { return http.StatusInternalServerError, fmt.Errorf("Failed to update PostComments for deleted comment: %v", err) } - _, err = DB.Exec(`UPDATE ProjectComments SET user_id = -1 WHERE comment_id = ?`, id) + _, err = tx.Exec(`UPDATE projectcomments SET user_id = -1 WHERE comment_id = $1`, id) if err != nil { return http.StatusInternalServerError, fmt.Errorf("Failed to update ProjectComments for deleted comment: %v", err) } - query := `UPDATE Comments SET user_id = -1, content = "This comment was deleted.", likes = 0, creation_date = ? WHERE id = ?` - res, err := DB.Exec(query, time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC), id) + query := `UPDATE comments SET user_id = -1, content = 'This comment was deleted.', media = '[]', likes = 0, creation_date = $1 WHERE id = $2` + res, err := tx.Exec(query, time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC), id) if err != nil { return http.StatusBadRequest, fmt.Errorf("Failed to soft delete comment `%v`: %v", id, err) } @@ -417,19 +553,19 @@ func QueryDeleteComment(id int) (int16, error) { return http.StatusOK, nil } -// QueryUpdateCommentContent updates comment's content +// QueryUpdateComment updates comment fields with validation on edit time. // // Parameters: // - id: The id of the comment to be updated -// - newContent: the updated content +// - updatedData: fields to update (content, media) // // Returns: // - int16: http status code // - error: An error if the operation fails. -func QueryUpdateCommentContent(id int, newContent string) (int16, error) { +func QueryUpdateComment(id int, updatedData map[string]interface{}) (int16, error) { // get comment creation time to validate time diff var createdAt time.Time - query := `SELECT creation_date FROM Comments WHERE id = ?` + query := `SELECT creation_date FROM comments WHERE id = $1` err := DB.QueryRow(query, id).Scan(&createdAt) if err != nil { if err == sql.ErrNoRows { @@ -444,17 +580,21 @@ func QueryUpdateCommentContent(id int, newContent string) (int16, error) { return http.StatusBadRequest, fmt.Errorf("Cannot update comment. More than 2 minutes have passed since posting.") } - query = `UPDATE Comments SET content = ? WHERE id = ?` - res, err := DB.Exec(query, newContent, id) + query = `UPDATE Comments SET ` + queryParams, args, err := BuildUpdateQuery(updatedData) if err != nil { - return http.StatusInternalServerError, fmt.Errorf("Failed to update comment content: %v", err) + return http.StatusBadRequest, err } + query += queryParams + query += fmt.Sprintf(" WHERE id = $%d", len(args)+1) + args = append(args, id) - rowsAffected, err := res.RowsAffected() + rowsAffected, err := ExecUpdate(query, args...) + if err != nil { + return http.StatusInternalServerError, fmt.Errorf("Failed to update comment: %v", err) + } if rowsAffected == 0 { return http.StatusNotFound, fmt.Errorf("Comment not found or no changes made") - } else if err != nil { - return http.StatusInternalServerError, fmt.Errorf("Failed to fetch affected rows: %v", err) } return http.StatusOK, nil @@ -491,7 +631,7 @@ func CreateCommentLike(username string, strCommentId string) (int, error) { // check if the like already exists var exists bool query := `SELECT EXISTS ( - SELECT 1 FROM CommentLikes WHERE user_id = ? AND comment_id = ? + SELECT 1 FROM commentlikes WHERE user_id = $1 AND comment_id = $2 )` err = DB.QueryRow(query, user_id, commentId).Scan(&exists) if err != nil { @@ -501,17 +641,29 @@ func CreateCommentLike(username string, strCommentId string) (int, error) { // like already exists, but we return success to keep it idempotent return http.StatusOK, nil } + tx, err := DB.Begin() + if err != nil { + return -1, fmt.Errorf("failed to begin transaction: %v", err) + } + + defer func() { + if err != nil { + tx.Rollback() + } else { + tx.Commit() + } + }() // insert the like - insertQuery := `INSERT INTO CommentLikes (user_id, comment_id) VALUES (?, ?)` - _, err = DB.Exec(insertQuery, user_id, commentId) + insertQuery := `INSERT INTO commentlikes (user_id, comment_id) VALUES ($1, $2)` + _, err = tx.Exec(insertQuery, user_id, commentId) if err != nil { return http.StatusInternalServerError, fmt.Errorf("Failed to insert comment like: %v", err) } // update the likes column - updateQuery := `UPDATE Comments SET likes = likes + 1 WHERE id = ?` - _, err = DB.Exec(updateQuery, commentId) + updateQuery := `UPDATE comments SET likes = likes + 1 WHERE id = $1` + _, err = tx.Exec(updateQuery, commentId) if err != nil { return http.StatusInternalServerError, fmt.Errorf("Failed to update likes count: %v", err) } @@ -547,9 +699,22 @@ func RemoveCommentLike(username string, strCommentId string) (int, error) { return http.StatusInternalServerError, fmt.Errorf("An error occurred verifying the comment exists: %v", err) } + tx, err := DB.Begin() + if err != nil { + return -1, fmt.Errorf("failed to begin transaction: %v", err) + } + + defer func() { + if err != nil { + tx.Rollback() + } else { + tx.Commit() + } + }() + // perform the delete operation - deleteQuery := `DELETE FROM CommentLikes WHERE user_id = ? AND comment_id = ?` - result, err := DB.Exec(deleteQuery, user_id, commentId) + deleteQuery := `DELETE FROM commentlikes WHERE user_id = $1 AND comment_id = $2` + result, err := tx.Exec(deleteQuery, user_id, commentId) if err != nil { return http.StatusInternalServerError, fmt.Errorf("Failed to delete comment like: %v", err) } @@ -566,8 +731,8 @@ func RemoveCommentLike(username string, strCommentId string) (int, error) { } // update the likes column - updateQuery := `UPDATE Comments SET likes = likes - 1 WHERE id = ?` - _, err = DB.Exec(updateQuery, commentId) + updateQuery := `UPDATE comments SET likes = likes - 1 WHERE id = $1` + _, err = tx.Exec(updateQuery, commentId) if err != nil { return http.StatusInternalServerError, fmt.Errorf("Failed to update likes count: %v", err) } @@ -607,7 +772,7 @@ func QueryCommentLike(username string, strCommId string) (int, bool, error) { // check if the like already exists var exists bool query := `SELECT EXISTS ( - SELECT 1 FROM CommentLikes WHERE user_id = ? AND comment_id = ? + SELECT 1 FROM commentlikes WHERE user_id = $1 AND comment_id = $2 )` err = DB.QueryRow(query, user_id, commId).Scan(&exists) if err != nil { @@ -638,7 +803,7 @@ func QueryIsCommentEditable(strCommId string) (int, bool, error) { } var createdAt time.Time - query := `SELECT creation_date FROM Comments WHERE id = ?` + query := `SELECT creation_date FROM comments WHERE id = $1` err = DB.QueryRow(query, commId).Scan(&createdAt) if err != nil { if err == sql.ErrNoRows { @@ -650,7 +815,7 @@ func QueryIsCommentEditable(strCommId string) (int, bool, error) { now := time.Now().UTC() // now check if the comment is older than 2 minutes if now.Sub(createdAt) > 2*time.Minute { - return http.StatusOK, false, nil + return http.StatusOK, false, nil } else { return http.StatusOK, true, nil } diff --git a/backend/api/internal/database/create_tables.sql b/backend/api/internal/database/create_tables.sql index 9f844ab..fd80043 100644 --- a/backend/api/internal/database/create_tables.sql +++ b/backend/api/internal/database/create_tables.sql @@ -1,140 +1,202 @@ --- Drop tables if they already exist -DROP TABLE IF EXISTS UserLoginInfo; - -DROP TABLE IF EXISTS Users; -DROP TABLE IF EXISTS UserFollows; - -DROP TABLE IF EXISTS Projects; -DROP TABLE IF EXISTS ProjectLikes; -DROP TABLE IF EXISTS ProjectFollows; -DROP TABLE IF EXISTS ProjectComments; - -DROP TABLE IF EXISTS Posts; -DROP TABLE IF EXISTS PostLikes; -DROP TABLE IF EXISTS PostComments; - -DROP TABLE IF EXISTS Comments; -DROP TABLE IF EXISTS CommentLikes; - -- UserLoginInfo -CREATE TABLE UserLoginInfo ( +CREATE TABLE IF NOT EXISTS userlogininfo ( username VARCHAR(50) UNIQUE NOT NULL, - password VARCHAR(100) NOT NULL, + password_hash VARCHAR(255) NOT NULL, PRIMARY KEY(username) ); -- Users Table -CREATE TABLE Users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, username VARCHAR(50) UNIQUE NOT NULL, picture TEXT, bio TEXT, links JSON, + settings JSON, creation_date TIMESTAMP NOT NULL ); -- Projects Table -CREATE TABLE Projects ( - id INTEGER PRIMARY KEY AUTOINCREMENT, +CREATE TABLE IF NOT EXISTS projects ( + id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, description TEXT, + about_md TEXT, status INTEGER, likes INTEGER DEFAULT 0, links JSON, tags JSON, + media JSON, owner INTEGER NOT NULL, creation_date TIMESTAMP NOT NULL, - FOREIGN KEY (owner) REFERENCES Users(id) ON DELETE CASCADE + FOREIGN KEY (owner) REFERENCES users(id) ON DELETE CASCADE +); + +-- Project Builders (Collaborators) +CREATE TABLE IF NOT EXISTS projectbuilders ( + project_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + PRIMARY KEY (project_id, user_id), + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +-- Comments table +CREATE TABLE IF NOT EXISTS comments ( + id SERIAL PRIMARY KEY, + parent_comment_id INTEGER, + user_id INTEGER NOT NULL, + content TEXT NOT NULL, + media JSON, + likes INTEGER DEFAULT 0, + creation_date TIMESTAMP NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (parent_comment_id) REFERENCES comments(id) ON DELETE CASCADE ); -- Project Comments Table (Normalizing comments relationship) -CREATE TABLE ProjectComments ( +CREATE TABLE IF NOT EXISTS projectcomments ( project_id INTEGER NOT NULL, comment_id INTEGER NOT NULL, user_id INTEGER NOT NULL, - FOREIGN KEY (user_id) REFERENCES Users(id) ON DELETE CASCADE, - FOREIGN KEY (project_id) REFERENCES Projects(id) ON DELETE CASCADE, - FOREIGN KEY (comment_id) REFERENCES Comments(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, + FOREIGN KEY (comment_id) REFERENCES comments(id) ON DELETE CASCADE, PRIMARY KEY (project_id, comment_id) ); -- Posts Table -CREATE TABLE Posts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, +CREATE TABLE IF NOT EXISTS posts ( + id SERIAL PRIMARY KEY, content TEXT NOT NULL, - project_id INTEGER NOT NULL, + media JSON, + project_id INTEGER, creation_date TIMESTAMP NOT NULL, user_id INTEGER NOT NULL, likes INTEGER DEFAULT 0, - FOREIGN KEY (project_id) REFERENCES Projects(id) ON DELETE CASCADE, - FOREIGN KEY (user_id) REFERENCES Users(id) ON DELETE CASCADE + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); --- Project Comments Table (Normalizing comments relationship) -CREATE TABLE PostComments ( +-- Post Comments Table (Normalizing comments relationship) +CREATE TABLE IF NOT EXISTS postcomments ( post_id INTEGER NOT NULL, comment_id INTEGER NOT NULL, user_id INTEGER NOT NULL, - FOREIGN KEY (user_id) REFERENCES Users(id) ON DELETE CASCADE, - FOREIGN KEY (post_id) REFERENCES Posts(id) ON DELETE CASCADE, - FOREIGN KEY (comment_id) REFERENCES Comments(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE, + FOREIGN KEY (comment_id) REFERENCES comments(id) ON DELETE CASCADE, PRIMARY KEY (post_id, comment_id) ); --- Comments Table -CREATE TABLE Comments ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - content TEXT NOT NULL, - parent_comment_id INTEGER, - likes INTEGER DEFAULT 0, - creation_date TIMESTAMP NOT NULL, - user_id INTEGER NOT NULL, - FOREIGN KEY (parent_comment_id) REFERENCES Comments(id) ON DELETE CASCADE, - FOREIGN KEY (user_id) REFERENCES Users(id) ON DELETE CASCADE +-- User Follows Table +CREATE TABLE IF NOT EXISTS userfollows ( + follower_id INTEGER NOT NULL, + followed_id INTEGER NOT NULL, + PRIMARY KEY (follower_id, followed_id), + FOREIGN KEY (follower_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (followed_id) REFERENCES users(id) ON DELETE CASCADE ); --- Likes for Projects -CREATE TABLE ProjectLikes ( +-- Project Likes Table +CREATE TABLE IF NOT EXISTS projectlikes ( + user_id INTEGER NOT NULL, project_id INTEGER NOT NULL, + PRIMARY KEY (user_id, project_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE +); + +-- Project Follows Table +CREATE TABLE IF NOT EXISTS projectfollows ( user_id INTEGER NOT NULL, - PRIMARY KEY (project_id, user_id), - FOREIGN KEY (project_id) REFERENCES Projects(id) ON DELETE CASCADE, - FOREIGN KEY (user_id) REFERENCES Users(id) ON DELETE CASCADE + project_id INTEGER NOT NULL, + PRIMARY KEY (user_id, project_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE ); --- Likes for Posts -CREATE TABLE PostLikes ( - post_id INTEGER NOT NULL, +-- Post Likes Table +CREATE TABLE IF NOT EXISTS postlikes ( user_id INTEGER NOT NULL, - PRIMARY KEY (post_id, user_id), - FOREIGN KEY (post_id) REFERENCES Posts(id) ON DELETE CASCADE, - FOREIGN KEY (user_id) REFERENCES Users(id) ON DELETE CASCADE + post_id INTEGER NOT NULL, + PRIMARY KEY (user_id, post_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE ); --- Likes for Comments -CREATE TABLE CommentLikes ( +-- Comment Likes Table +CREATE TABLE IF NOT EXISTS commentlikes ( + user_id INTEGER NOT NULL, comment_id INTEGER NOT NULL, + PRIMARY KEY (user_id, comment_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (comment_id) REFERENCES comments(id) ON DELETE CASCADE +); + +-- Post Saves Table +CREATE TABLE IF NOT EXISTS postsaves ( user_id INTEGER NOT NULL, - PRIMARY KEY (comment_id, user_id), - FOREIGN KEY (comment_id) REFERENCES Comments(id) ON DELETE CASCADE, - FOREIGN KEY (user_id) REFERENCES Users(id) ON DELETE CASCADE + post_id INTEGER NOT NULL, + PRIMARY KEY (user_id, post_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE ); --- Follows between Users (User Following) -CREATE TABLE UserFollows ( - follower_id INTEGER NOT NULL, - follows_id INTEGER NOT NULL, - PRIMARY KEY (follower_id, follows_id), - FOREIGN KEY (follower_id) REFERENCES Users(id) ON DELETE CASCADE, - FOREIGN KEY (follows_id) REFERENCES Users(id) ON DELETE CASCADE, - CHECK (follower_id != follows_id) +-- Direct Messages Table +CREATE TABLE IF NOT EXISTS directmessages ( + id SERIAL PRIMARY KEY, + sender_id INTEGER NOT NULL, + recipient_id INTEGER NOT NULL, + content TEXT NOT NULL, + creation_date TIMESTAMP NOT NULL, + read_at TIMESTAMP, + FOREIGN KEY (sender_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (recipient_id) REFERENCES users(id) ON DELETE CASCADE ); --- Follows for Projects (User Following a Project) -CREATE TABLE ProjectFollows ( - project_id INTEGER NOT NULL, +-- Notifications Table +CREATE TABLE IF NOT EXISTS notifications ( + id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL, - PRIMARY KEY (project_id, user_id), - FOREIGN KEY (project_id) REFERENCES Projects(id) ON DELETE CASCADE, - FOREIGN KEY (user_id) REFERENCES Users(id) ON DELETE CASCADE + actor_id INTEGER NOT NULL, + type VARCHAR(50) NOT NULL, + post_id INTEGER, + project_id INTEGER, + comment_id INTEGER, + created_at TIMESTAMP NOT NULL, + read_at TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (actor_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE, + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, + FOREIGN KEY (comment_id) REFERENCES comments(id) ON DELETE CASCADE ); + +-- User Push Tokens Table +CREATE TABLE IF NOT EXISTS userpushtokens ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL, + token TEXT UNIQUE NOT NULL, + platform TEXT, + created_at TIMESTAMP NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +-- Indexes for performance +CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); +CREATE INDEX IF NOT EXISTS idx_projects_owner ON projects(owner); +CREATE INDEX IF NOT EXISTS idx_posts_user_id ON posts(user_id); +CREATE INDEX IF NOT EXISTS idx_posts_project_id ON posts(project_id); +CREATE INDEX IF NOT EXISTS idx_comments_user_id ON comments(user_id); +CREATE INDEX IF NOT EXISTS idx_userfollows_follower_id ON userfollows(follower_id); +CREATE INDEX IF NOT EXISTS idx_userfollows_followed_id ON userfollows(followed_id); +CREATE INDEX IF NOT EXISTS idx_projectlikes_project_id ON projectlikes(project_id); +CREATE INDEX IF NOT EXISTS idx_projectfollows_project_id ON projectfollows(project_id); +CREATE INDEX IF NOT EXISTS idx_postlikes_post_id ON postlikes(post_id); +CREATE INDEX IF NOT EXISTS idx_commentlikes_comment_id ON commentlikes(comment_id); +CREATE INDEX IF NOT EXISTS idx_directmessages_sender_recipient ON directmessages(sender_id, recipient_id); +CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id); +CREATE INDEX IF NOT EXISTS idx_notifications_user_created_at ON notifications(user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_notifications_user_read_at ON notifications(user_id, read_at); +CREATE INDEX IF NOT EXISTS idx_userpushtokens_user_id ON userpushtokens(user_id); diff --git a/backend/api/internal/database/create_test_data.sql b/backend/api/internal/database/create_test_data.sql index 069c389..8cb6aef 100644 --- a/backend/api/internal/database/create_test_data.sql +++ b/backend/api/internal/database/create_test_data.sql @@ -1,17 +1,17 @@ -- Users -INSERT INTO Users (username, picture, bio, links, creation_date) VALUES - ('dev_user1', 'https://example.com/dev_user1.jpg', 'Full-stack developer passionate about open-source projects.', '["https://github.com/dev_user1", "https://devuser1.com"]', '2023-12-13 00:00:00'), - ('tech_writer2', 'https://example.com/tech_writer2.jpg', 'Technical writer and Python enthusiast.', '["https://blog.techwriter.com"]', '2022-12-13 00:00:00'), - ('data_scientist3', 'https://example.com/data_scientist3.jpg', 'Data scientist with a passion for machine learning.', '["https://github.com/data_scientist3", "https://datascientist3.com"]', '2023-06-13 00:00:00'), - ('backend_guru4', 'https://example.com/backend_guru4.jpg', 'Backend expert specializing in scalable systems.', '["https://github.com/backend_guru4"]', '2024-01-15 00:00:00'), - ('ui_designer5', 'https://example.com/ui_designer5.jpg', 'UI/UX designer with a love for user-friendly apps.', '["https://portfolio.uidesigner5.com"]', '2023-05-10 00:00:00'); +INSERT INTO Users (username, picture, bio, links, settings, creation_date) VALUES + ('dev_user1', 'https://example.com/dev_user1.jpg', 'Full-stack developer passionate about open-source projects.', '["https://github.com/dev_user1", "https://devuser1.com"]', '{"accentColor":"","backgroundRefreshEnabled":false,"refreshIntervalMs":120000,"zenMode":false,"compactMode":false}', '2023-12-13 00:00:00'), + ('tech_writer2', 'https://example.com/tech_writer2.jpg', 'Technical writer and Python enthusiast.', '["https://blog.techwriter.com"]', '{"accentColor":"","backgroundRefreshEnabled":false,"refreshIntervalMs":120000,"zenMode":false,"compactMode":false}', '2022-12-13 00:00:00'), + ('data_scientist3', 'https://example.com/data_scientist3.jpg', 'Data scientist with a passion for machine learning.', '["https://github.com/data_scientist3", "https://datascientist3.com"]', '{"accentColor":"","backgroundRefreshEnabled":false,"refreshIntervalMs":120000,"zenMode":false,"compactMode":false}', '2023-06-13 00:00:00'), + ('backend_guru4', 'https://example.com/backend_guru4.jpg', 'Backend expert specializing in scalable systems.', '["https://github.com/backend_guru4"]', '{"accentColor":"","backgroundRefreshEnabled":false,"refreshIntervalMs":120000,"zenMode":false,"compactMode":false}', '2024-01-15 00:00:00'), + ('ui_designer5', 'https://example.com/ui_designer5.jpg', 'UI/UX designer with a love for user-friendly apps.', '["https://portfolio.uidesigner5.com"]', '{"accentColor":"","backgroundRefreshEnabled":false,"refreshIntervalMs":120000,"zenMode":false,"compactMode":false}', '2023-05-10 00:00:00'); -- Projects INSERT INTO Projects (name, description, status, likes, tags, links, owner, creation_date) VALUES - ('OpenAPI Toolkit', 'A toolkit for generating and testing OpenAPI specs.', 1, 120, '["OpenAPI", "Go", "Tooling"]', '["https://github.com/dev_user1/openapi-toolkit"]', (SELECT id FROM Users WHERE username = 'dev_user1'), '2023-06-13 00:00:00'), - ('DocuHelper', 'A library for streamlining technical documentation processes.', 2, 85, '["Documentation", "Python"]', '["https://github.com/tech_writer2/docuhelper"]', (SELECT id FROM Users WHERE username = 'tech_writer2'), '2021-12-13 00:00:00'), - ('ML Research', 'Research repository for various machine learning algorithms.', 1, 45, '["Machine Learning", "Python", "Research"]', '["https://github.com/data_scientist3/ml-research"]', (SELECT id FROM Users WHERE username = 'data_scientist3'), '2024-09-13 00:00:00'), - ('ScaleDB', 'A scalable database system for modern apps.', 1, 70, '["Database", "Scalability", "Backend"]', '["https://github.com/backend_guru4/scaledb"]', (SELECT id FROM Users WHERE username = 'backend_guru4'), '2024-03-15 00:00:00'); + ('OpenAPI Toolkit', 'A toolkit for generating and testing OpenAPI specs.', 1, 120, '["OpenAPI", "Go", "Tooling"]', '["https://github.com/dev_user1/openapi-toolkit"]', 1, '2023-06-13 00:00:00'), + ('DocuHelper', 'A library for streamlining technical documentation processes.', 2, 85, '["Documentation", "Python"]', '["https://github.com/tech_writer2/docuhelper"]', 2, '2021-12-13 00:00:00'), + ('ML Research', 'Research repository for various machine learning algorithms.', 1, 45, '["Machine Learning", "Python", "Research"]', '["https://github.com/data_scientist3/ml-research"]', 3, '2024-09-13 00:00:00'), + ('ScaleDB', 'A scalable database system for modern apps.', 1, 70, '["Database", "Scalability", "Backend"]', '["https://github.com/backend_guru4/scaledb"]', 4, '2024-03-15 00:00:00'); -- Posts INSERT INTO Posts (content, project_id, creation_date, user_id, likes) VALUES @@ -102,7 +102,7 @@ INSERT INTO PostLikes (post_id, user_id) VALUES (SELECT id FROM Users WHERE username = 'ui_designer5')); -- User Follows (Additional follows between existing users) -INSERT INTO UserFollows (follower_id, follows_id) VALUES +INSERT INTO UserFollows (follower_id, followed_id) VALUES ((SELECT id FROM Users WHERE username = 'dev_user1'), (SELECT id FROM Users WHERE username = 'data_scientist3')), ((SELECT id FROM Users WHERE username = 'tech_writer2'), diff --git a/backend/api/internal/database/db.go b/backend/api/internal/database/db.go index a208815..3df7872 100644 --- a/backend/api/internal/database/db.go +++ b/backend/api/internal/database/db.go @@ -2,24 +2,170 @@ package database import ( "database/sql" + "fmt" "log" + "os" + "path/filepath" + "strings" + "time" + + _ "github.com/lib/pq" // PostgreSQL driver ) var DB *sql.DB // Global database instance +// driverName stores the active database driver ("postgres" or "sqlite"). +var driverName string + // Connect initializes a database connection -func Connect(dsn string, driverName string) { +func Connect() { var err error + var dsn string + + // Prefer PostgreSQL connection if DATABASE_URL is set + dbURL := os.Getenv("DATABASE_URL") + if dbURL != "" { + driverName = "postgres" + dsn = dbURL + } else { + // Fallback to SQLite for local development + driverName = "sqlite" + dbPath := filepath.Join(".", "api", "internal", "database", "dev.sqlite3") + dsn = dbPath + } + DB, err = sql.Open(driverName, dsn) if err != nil { - log.Fatalf("Failed to connect to the database: %v", err) + log.Fatalf("Failed to open database connection: %v", err) + } + + // Verify connection (retry for postgres to handle cold starts / deploy races) + if driverName == "postgres" { + const maxAttempts = 30 + const retryDelay = 2 * time.Second + for attempt := 1; attempt <= maxAttempts; attempt++ { + err = DB.Ping() + if err == nil { + break + } + log.Printf("Postgres ping attempt %d/%d failed: %v", attempt, maxAttempts, err) + if attempt < maxAttempts { + time.Sleep(retryDelay) + } + } + if err != nil { + log.Fatalf("Failed to ping database after retries: %v", err) + } + } else { + err = DB.Ping() + if err != nil { + log.Fatalf("Failed to ping database: %v", err) + } + } + + // Apply driver-specific configurations + if driverName == "postgres" { + if err := ensurePostgresSchema(); err != nil { + log.Fatalf("Failed to initialize postgres database schema: %v", err) + } + } else if driverName == "sqlite" { + if _, err := DB.Exec("PRAGMA foreign_keys=ON;"); err != nil { + log.Printf("WARN: failed to enable foreign keys: %v", err) + } + if _, err := DB.Exec("PRAGMA journal_mode=WAL;"); err != nil { + log.Printf("WARN: failed to set WAL mode: %v", err) + } + if _, err := DB.Exec("PRAGMA busy_timeout=5000;"); err != nil { + log.Printf("WARN: failed to set busy timeout: %v", err) + } + if err := ensureSqliteSchema(); err != nil { + log.Fatalf("Failed to initialize sqlite database schema: %v", err) + } + } + + log.Printf("Database connected successfully using %s driver", driverName) +} + +func ensurePostgresSchema() error { + if err := execSqlFile("create_tables.sql"); err != nil { + return err + } + if err := ensureDirectMessageIntegrityForPostgres(); err != nil { + return err + } + log.Println("PostgreSQL schema ensured successfully.") + return nil +} + +func ensureSqliteSchema() error { + // For SQLite, we'll just run the schema file every time. + // This is simple and effective for a dev database. + if err := execSqlFile("create_tables.sql"); err != nil { + return err } + return nil +} - // Verify connection - err = DB.Ping() +func execSqlFile(filename string) error { + path := filepath.Join("api", "internal", "database", filename) + content, err := os.ReadFile(path) if err != nil { - log.Fatalf("Failed to ping database: %v", err) + return fmt.Errorf("failed to read %s: %w", path, err) + } + + statements := strings.Split(string(content), ";") + for _, statement := range statements { + stmt := strings.TrimSpace(statement) + if stmt == "" { + continue + } + if _, err := DB.Exec(stmt); err != nil { + return fmt.Errorf("failed to exec statement in %s: %w", filename, err) + } + } + + return nil +} + +func ensureDirectMessageIntegrityForPostgres() error { + if _, err := DB.Exec(`DELETE FROM directmessages dm +WHERE NOT EXISTS (SELECT 1 FROM users u WHERE u.id = dm.sender_id) + OR NOT EXISTS (SELECT 1 FROM users u WHERE u.id = dm.recipient_id);`); err != nil { + return fmt.Errorf("failed to clean orphaned direct messages: %w", err) + } + + if _, err := DB.Exec(`DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'directmessages_sender_id_fkey' + ) THEN + ALTER TABLE directmessages + ADD CONSTRAINT directmessages_sender_id_fkey + FOREIGN KEY (sender_id) REFERENCES users(id) ON DELETE CASCADE; + END IF; +END $$;`); err != nil { + return fmt.Errorf("failed to ensure sender foreign key on directmessages: %w", err) + } + + if _, err := DB.Exec(`DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'directmessages_recipient_id_fkey' + ) THEN + ALTER TABLE directmessages + ADD CONSTRAINT directmessages_recipient_id_fkey + FOREIGN KEY (recipient_id) REFERENCES users(id) ON DELETE CASCADE; + END IF; +END $$;`); err != nil { + return fmt.Errorf("failed to ensure recipient foreign key on directmessages: %w", err) + } + + if _, err := DB.Exec(`CREATE INDEX IF NOT EXISTS idx_directmessages_created_at +ON directmessages (creation_date DESC, id DESC);`); err != nil { + return fmt.Errorf("failed to ensure directmessages recency index: %w", err) } - log.Println("Database connected successfully") + return nil } diff --git a/backend/api/internal/database/dev.sqlite3 b/backend/api/internal/database/dev.sqlite3 index a7652ec..bcf658a 100644 Binary files a/backend/api/internal/database/dev.sqlite3 and b/backend/api/internal/database/dev.sqlite3 differ diff --git a/backend/api/internal/database/dev.sqlite3-shm b/backend/api/internal/database/dev.sqlite3-shm new file mode 100644 index 0000000..fe9ac28 Binary files /dev/null and b/backend/api/internal/database/dev.sqlite3-shm differ diff --git a/backend/api/internal/database/dev.sqlite3-wal b/backend/api/internal/database/dev.sqlite3-wal new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/internal/database/direct_message_queries.go b/backend/api/internal/database/direct_message_queries.go new file mode 100644 index 0000000..ce69219 --- /dev/null +++ b/backend/api/internal/database/direct_message_queries.go @@ -0,0 +1,238 @@ +package database + +import ( + "database/sql" + "fmt" + "net/http" + "time" +) + +type DirectMessage struct { + ID int64 `json:"id"` + SenderID int64 `json:"sender_id"` + RecipientID int64 `json:"recipient_id"` + SenderName string `json:"sender_name"` + RecipientName string `json:"recipient_name"` + Content string `json:"content"` + CreatedAt time.Time `json:"created_at"` +} + +type DirectMessageThread struct { + PeerUsername string `json:"peer_username"` + LastContent string `json:"last_content"` + LastAt time.Time `json:"last_at"` +} + +func QueryCreateDirectMessage(senderUsername string, recipientUsername string, content string) (*DirectMessage, int, error) { + if senderUsername == "" || recipientUsername == "" { + return nil, http.StatusBadRequest, fmt.Errorf("sender and recipient are required") + } + if content == "" { + return nil, http.StatusBadRequest, fmt.Errorf("message content is required") + } + + senderID, err := GetUserIdByUsername(senderUsername) + if err != nil { + return nil, http.StatusNotFound, fmt.Errorf("sender '%s' not found", senderUsername) + } + recipientID, err := GetUserIdByUsername(recipientUsername) + if err != nil { + return nil, http.StatusNotFound, fmt.Errorf("recipient '%s' not found", recipientUsername) + } + + createdAt := time.Now().UTC() + var messageID int64 + err = DB.QueryRow( + `INSERT INTO directmessages (sender_id, recipient_id, content, creation_date) VALUES ($1, $2, $3, $4) RETURNING id;`, + senderID, + recipientID, + content, + createdAt, + ).Scan(&messageID) + if err != nil { + return nil, http.StatusInternalServerError, fmt.Errorf("failed to insert direct message: %w", err) + } + + resolvedSender, senderErr := GetUserById(senderID) + if senderErr != nil || resolvedSender == nil { + return nil, http.StatusInternalServerError, fmt.Errorf("failed to resolve sender username: %w", senderErr) + } + resolvedRecipient, recipientErr := GetUserById(recipientID) + if recipientErr != nil || resolvedRecipient == nil { + return nil, http.StatusInternalServerError, fmt.Errorf("failed to resolve recipient username: %w", recipientErr) + } + + message := &DirectMessage{ + ID: messageID, + SenderID: int64(senderID), + RecipientID: int64(recipientID), + SenderName: resolvedSender.Username, + RecipientName: resolvedRecipient.Username, + Content: content, + CreatedAt: createdAt, + } + return message, http.StatusCreated, nil +} + +func QueryDirectMessages(username string, otherUsername string, start int, count int) ([]DirectMessage, int, error) { + if username == "" || otherUsername == "" { + return nil, http.StatusBadRequest, fmt.Errorf("username and other username are required") + } + if start < 0 || count <= 0 { + return nil, http.StatusBadRequest, fmt.Errorf("invalid pagination params") + } + + userID, err := GetUserIdByUsername(username) + if err != nil { + return nil, http.StatusNotFound, fmt.Errorf("user '%s' not found", username) + } + otherID, err := GetUserIdByUsername(otherUsername) + if err != nil { + return nil, http.StatusNotFound, fmt.Errorf("user '%s' not found", otherUsername) + } + + query := `SELECT + dm.id, + dm.sender_id, + dm.recipient_id, + sender.username, + recipient.username, + dm.content, + dm.creation_date + FROM directmessages dm + JOIN users sender ON sender.id = dm.sender_id + JOIN users recipient ON recipient.id = dm.recipient_id + WHERE (dm.sender_id = $1 AND dm.recipient_id = $2) + OR (dm.sender_id = $3 AND dm.recipient_id = $4) + ORDER BY dm.creation_date ASC + LIMIT $5 OFFSET $6;` + + rows, err := DB.Query(query, userID, otherID, otherID, userID, count, start) + if err != nil { + return nil, http.StatusInternalServerError, fmt.Errorf("failed to query direct messages: %w", err) + } + defer rows.Close() + + messages := make([]DirectMessage, 0) + for rows.Next() { + var message DirectMessage + if err := rows.Scan( + &message.ID, + &message.SenderID, + &message.RecipientID, + &message.SenderName, + &message.RecipientName, + &message.Content, + &message.CreatedAt, + ); err != nil { + return nil, http.StatusInternalServerError, fmt.Errorf("failed to scan direct message: %w", err) + } + messages = append(messages, message) + } + if err := rows.Err(); err != nil { + return nil, http.StatusInternalServerError, fmt.Errorf("direct message rows error: %w", err) + } + + return messages, http.StatusOK, nil +} + +func QueryDirectChatPeers(username string) ([]string, int, error) { + if username == "" { + return nil, http.StatusBadRequest, fmt.Errorf("username is required") + } + + userID, err := GetUserIdByUsername(username) + if err != nil { + return nil, http.StatusNotFound, fmt.Errorf("user '%s' not found", username) + } + + query := `SELECT DISTINCT u.username + FROM directmessages dm + JOIN users u ON u.id = CASE + WHEN dm.sender_id = $1 THEN dm.recipient_id + ELSE dm.sender_id + END + WHERE dm.sender_id = $2 OR dm.recipient_id = $3 + ORDER BY u.username ASC;` + + rows, err := DB.Query(query, userID, userID, userID) + if err != nil { + return nil, http.StatusInternalServerError, fmt.Errorf("failed to query chat peers: %w", err) + } + defer rows.Close() + + peers := make([]string, 0) + for rows.Next() { + var peer sql.NullString + if err := rows.Scan(&peer); err != nil { + return nil, http.StatusInternalServerError, fmt.Errorf("failed to scan chat peer: %w", err) + } + if peer.Valid && peer.String != "" { + peers = append(peers, peer.String) + } + } + if err := rows.Err(); err != nil { + return nil, http.StatusInternalServerError, fmt.Errorf("chat peer rows error: %w", err) + } + + return peers, http.StatusOK, nil +} + +func QueryDirectMessageThreads(username string, start int, count int) ([]DirectMessageThread, int, error) { + if username == "" { + return nil, http.StatusBadRequest, fmt.Errorf("username is required") + } + if start < 0 || count <= 0 { + return nil, http.StatusBadRequest, fmt.Errorf("invalid pagination params") + } + + userID, err := GetUserIdByUsername(username) + if err != nil { + return nil, http.StatusNotFound, fmt.Errorf("user '%s' not found", username) + } + + query := `WITH ranked_threads AS ( + SELECT + CASE + WHEN dm.sender_id = $1 THEN dm.recipient_id + ELSE dm.sender_id + END AS peer_id, + dm.content, + dm.creation_date, + ROW_NUMBER() OVER ( + PARTITION BY CASE + WHEN dm.sender_id = $1 THEN dm.recipient_id + ELSE dm.sender_id + END + ORDER BY dm.creation_date DESC, dm.id DESC + ) AS rank_in_thread + FROM directmessages dm + WHERE dm.sender_id = $1 OR dm.recipient_id = $1 + ) + SELECT u.username, rt.content, rt.creation_date + FROM ranked_threads rt + JOIN users u ON u.id = rt.peer_id + WHERE rt.rank_in_thread = 1 + ORDER BY rt.creation_date DESC + LIMIT $2 OFFSET $3;` + + rows, err := DB.Query(query, userID, count, start) + if err != nil { + return nil, http.StatusInternalServerError, fmt.Errorf("failed to query direct message threads: %w", err) + } + defer rows.Close() + + threads := make([]DirectMessageThread, 0) + for rows.Next() { + var thread DirectMessageThread + if err := rows.Scan(&thread.PeerUsername, &thread.LastContent, &thread.LastAt); err != nil { + return nil, http.StatusInternalServerError, fmt.Errorf("failed to scan direct message thread: %w", err) + } + threads = append(threads, thread) + } + if err := rows.Err(); err != nil { + return nil, http.StatusInternalServerError, fmt.Errorf("direct message thread rows error: %w", err) + } + + return threads, http.StatusOK, nil +} diff --git a/backend/api/internal/database/feed_queries.go b/backend/api/internal/database/feed_queries.go index 41a3c7d..3c2055b 100644 --- a/backend/api/internal/database/feed_queries.go +++ b/backend/api/internal/database/feed_queries.go @@ -2,58 +2,181 @@ package database import ( "database/sql" + "fmt" "net/http" - - "backend/api/internal/types" + "strings" ) -// GetPostByTimeFeed retrieves a set of posts for the feed given a type -// it also paginates the results, sorted by most recent -// -// Parameters: -// - start: the int id to start at -// - count: the amount of posts to return -// -// Returns: -// - []types.Post: the list of posts for the feed -// - int: http status code -// - error: An error if the function fails, nil otherwise -func GetPostByTimeFeed(start int, count int) ([]types.Post, int, error) { - query := `SELECT id, user_id, project_id, content, likes, creation_date - FROM Posts - ORDER BY creation_date DESC - LIMIT ? OFFSET ?;` +func normalizeFeedSort(sort string) string { + switch strings.ToLower(strings.TrimSpace(sort)) { + case "likes", "popular": + return "popular" + case "hot": + return "hot" + case "new", "recent", "time", "": + return "recent" + default: + return "recent" + } +} + +func postOrderBy(sort string, alias string) string { + prefix := "" + if alias != "" { + prefix = alias + "." + } + + likesExpr := fmt.Sprintf("COALESCE((SELECT COUNT(*) FROM postlikes pl_hot WHERE pl_hot.post_id = %sid), 0)", prefix) + + normalized := normalizeFeedSort(sort) + // Use EXTRACT(EPOCH FROM (NOW() - %screation_date)) for PostgreSQL + hotnessFormula := fmt.Sprintf(`((%s + COALESCE((SELECT COUNT(*) FROM postsaves ps_hot WHERE ps_hot.post_id = %sid), 0) * 2.0) / (EXTRACT(EPOCH FROM (NOW() - %screation_date))/3600 + 2.0))`, likesExpr, prefix, prefix) + + switch normalized { + case "popular": + return fmt.Sprintf("ORDER BY %s DESC, %screation_date DESC", likesExpr, prefix) + case "hot": + return fmt.Sprintf(`ORDER BY %s DESC, %screation_date DESC`, hotnessFormula, prefix) + default: + return fmt.Sprintf("ORDER BY %screation_date DESC", prefix) + } +} + +func projectOrderBy(sort string, alias string) string { + prefix := "" + if alias != "" { + prefix = alias + "." + } + + normalized := normalizeFeedSort(sort) + // Use EXTRACT(EPOCH FROM (NOW() - %screation_date)) for PostgreSQL + hotnessFormula := fmt.Sprintf(`((%slikes + COALESCE((SELECT COUNT(*) FROM projectfollows pf_hot WHERE pf_hot.project_id = %sid), 0) * 2.0) / (EXTRACT(EPOCH FROM (NOW() - %screation_date))/3600 + 2.0))`, prefix, prefix, prefix) + + switch normalized { + case "popular": + return fmt.Sprintf("ORDER BY %slikes DESC, %screation_date DESC", prefix, prefix) + case "hot": + return fmt.Sprintf(`ORDER BY %s DESC, %screation_date DESC`, hotnessFormula, prefix) + default: + return fmt.Sprintf("ORDER BY %screation_date DESC", prefix) + } +} + +func getPostsFeedSorted(start int, count int, sort string) ([]Post, int, error) { + query := fmt.Sprintf(`SELECT id, user_id, project_id, content, COALESCE(media, '[]'), + COALESCE((SELECT COUNT(*) FROM postlikes pl WHERE pl.post_id = posts.id), 0), + COALESCE((SELECT COUNT(*) FROM postsaves ps WHERE ps.post_id = posts.id), 0), + creation_date + FROM posts + %s + LIMIT $1 OFFSET $2;`, postOrderBy(sort, "posts")) rows, err := DB.Query(query, count, start) if err != nil { return nil, http.StatusNotFound, err } defer rows.Close() - var posts []types.Post + var posts []Post for rows.Next() { - var post types.Post + var post Post + var mediaJSON string err := rows.Scan( &post.ID, &post.User, &post.Project, &post.Content, + &mediaJSON, &post.Likes, + &post.Saves, &post.CreationDate, ) if err != nil { if err == sql.ErrNoRows { - return []types.Post{}, http.StatusOK, nil + return []Post{}, http.StatusOK, nil } return nil, http.StatusInternalServerError, err } + if err := UnmarshalFromJSON(mediaJSON, &post.Media); err != nil { + return nil, http.StatusBadRequest, err + } posts = append(posts, post) } return posts, http.StatusOK, nil } +func getProjectsFeedSorted(start int, count int, sort string) ([]Project, int, error) { + query := fmt.Sprintf(`SELECT id, name, description, COALESCE(about_md, ''), status, likes, + COALESCE((SELECT COUNT(*) FROM projectfollows pf WHERE pf.project_id = projects.id), 0), + COALESCE(links, '[]'), COALESCE(tags, '[]'), COALESCE(media, '[]'), owner, creation_date + FROM projects + %s + LIMIT $1 OFFSET $2;`, projectOrderBy(sort, "projects")) + + rows, err := DB.Query(query, count, start) + if err != nil { + return nil, http.StatusNotFound, err + } + var projects []Project + defer rows.Close() + + for rows.Next() { + var project Project + var linksJSON, tagsJSON, mediaJSON string + err := rows.Scan( + &project.ID, + &project.Name, + &project.Description, + &project.AboutMd, + &project.Status, + &project.Likes, + &project.Saves, + &linksJSON, + &tagsJSON, + &mediaJSON, + &project.Owner, + &project.CreationDate, + ) + if err != nil { + if err == sql.ErrNoRows { + return nil, http.StatusOK, nil + } + return nil, http.StatusInternalServerError, err + } + + if err := UnmarshalFromJSON(linksJSON, &project.Links); err != nil { + return nil, http.StatusBadRequest, err + } + if err := UnmarshalFromJSON(tagsJSON, &project.Tags); err != nil { + return nil, http.StatusBadRequest, err + } + if err := UnmarshalFromJSON(mediaJSON, &project.Media); err != nil { + return nil, http.StatusBadRequest, err + } + + projects = append(projects, project) + } + + return projects, http.StatusOK, nil +} + +// GetPostByTimeFeed retrieves a set of posts for the feed given a type +// it also paginates the results, sorted by most recent +// +// Parameters: +// - start: the int id to start at +// - count: the amount of posts to return +// +// Returns: +// - []Post: the list of posts for the feed +// - int: http status code +// - error: An error if the function fails, nil otherwise +func GetPostByTimeFeed(start int, count int) ([]Post, int, error) { + return getPostsFeedSorted(start, count, "recent") +} + // GetPostByLikesFeed retrieves a set of posts for the feed given a type // it also paginates the results, sorted by most liked // @@ -62,86 +185,195 @@ func GetPostByTimeFeed(start int, count int) ([]types.Post, int, error) { // - count: the amount of posts to return // // Returns: -// - []types.Post: the list of posts for the feed +// - []Post: the list of posts for the feed // - int: http status code // - error: An error if the function fails, nil otherwise -func GetPostByLikesFeed(start int, count int) ([]types.Post, int, error) { - query := `SELECT id, user_id, project_id, content, likes, creation_date - FROM Posts - ORDER BY likes DESC - LIMIT ? OFFSET ?;` +func GetPostByLikesFeed(start int, count int) ([]Post, int, error) { + return getPostsFeedSorted(start, count, "popular") +} - rows, err := DB.Query(query, count, start) +// GetProjectByTimeFeed retrieves a set of projects for the feed given a type +// it also paginates the results, sorted by most recent +// +// Parameters: +// - start: the int id to start at +// - count: the amount of projects to return +// +// Returns: +// - []Project: the list of projects for the feed +// - int: http status code +// - error: An error if the function fails, nil otherwise +func GetProjectByTimeFeed(start int, count int) ([]Project, int, error) { + return getProjectsFeedSorted(start, count, "recent") +} + +// GetProjectByLikesFeed retrieves a set of projects for the feed given a type +// it also paginates the results, sorted by most liked +// +// Parameters: +// - start: the int id to start at +// - count: the amount of projects to return +// +// Returns: +// - []Project: the list of projects for the feed +// - int: http status code +// - error: An error if the function fails, nil otherwise +func GetProjectByLikesFeed(start int, count int) ([]Project, int, error) { + return getProjectsFeedSorted(start, count, "popular") +} + +func GetPostFeedBySort(start int, count int, sort string) ([]Post, int, error) { + return getPostsFeedSorted(start, count, sort) +} + +func GetProjectFeedBySort(start int, count int, sort string) ([]Project, int, error) { + return getProjectsFeedSorted(start, count, sort) +} + +func GetPostByFollowingFeed(username string, start int, count int, sort string) ([]Post, int, error) { + userID, err := GetUserIdByUsername(username) + if err != nil { + return nil, http.StatusNotFound, err + } + + query := fmt.Sprintf(`SELECT p.id, p.user_id, p.project_id, p.content, COALESCE(p.media, '[]'), + COALESCE((SELECT COUNT(*) FROM postlikes pl WHERE pl.post_id = p.id), 0), + COALESCE((SELECT COUNT(*) FROM postsaves ps WHERE ps.post_id = p.id), 0), + p.creation_date + FROM posts p + JOIN userfollows uf ON uf.followed_id = p.user_id + WHERE uf.follower_id = $1 + %s + LIMIT $2 OFFSET $3;`, postOrderBy(sort, "p")) + + rows, err := DB.Query(query, userID, count, start) if err != nil { return nil, http.StatusNotFound, err } defer rows.Close() - var posts []types.Post + posts := []Post{} for rows.Next() { - var post types.Post + var post Post + var mediaJSON string err := rows.Scan( &post.ID, &post.User, &post.Project, &post.Content, + &mediaJSON, &post.Likes, + &post.Saves, &post.CreationDate, ) + if err != nil { + if err == sql.ErrNoRows { + return []Post{}, http.StatusOK, nil + } + return nil, http.StatusInternalServerError, err + } + if err := UnmarshalFromJSON(mediaJSON, &post.Media); err != nil { + return nil, http.StatusBadRequest, err + } + posts = append(posts, post) + } + + return posts, http.StatusOK, nil +} +func GetPostBySavedFeed(username string, start int, count int, sort string) ([]Post, int, error) { + userID, err := GetUserIdByUsername(username) + if err != nil { + return nil, http.StatusNotFound, err + } + + query := fmt.Sprintf(`SELECT p.id, p.user_id, p.project_id, p.content, COALESCE(p.media, '[]'), + COALESCE((SELECT COUNT(*) FROM postlikes pl WHERE pl.post_id = p.id), 0), + COALESCE((SELECT COUNT(*) FROM postsaves ps2 WHERE ps2.post_id = p.id), 0), + p.creation_date + FROM posts p + JOIN postsaves ps ON ps.post_id = p.id + WHERE ps.user_id = $1 + %s + LIMIT $2 OFFSET $3;`, postOrderBy(sort, "p")) + + rows, err := DB.Query(query, userID, count, start) + if err != nil { + return nil, http.StatusNotFound, err + } + defer rows.Close() + + posts := []Post{} + for rows.Next() { + var post Post + var mediaJSON string + err := rows.Scan( + &post.ID, + &post.User, + &post.Project, + &post.Content, + &mediaJSON, + &post.Likes, + &post.Saves, + &post.CreationDate, + ) if err != nil { if err == sql.ErrNoRows { - return []types.Post{}, http.StatusOK, nil + return []Post{}, http.StatusOK, nil } return nil, http.StatusInternalServerError, err } + if err := UnmarshalFromJSON(mediaJSON, &post.Media); err != nil { + return nil, http.StatusBadRequest, err + } posts = append(posts, post) } return posts, http.StatusOK, nil } -// GetProjectByTimeFeed retrieves a set of projects for the feed given a type -// it also paginates the results, sorted by most recent -// -// Parameters: -// - start: the int id to start at -// - count: the amount of projects to return -// -// Returns: -// - []types.Project: the list of projects for the feed -// - int: http status code -// - error: An error if the function fails, nil otherwise -func GetProjectByTimeFeed(start int, count int) ([]types.Project, int, error) { - query := `SELECT id, name, description, status, likes, links, tags, owner, creation_date - FROM Project - ORDER BY likes DESC - LIMIT ? OFFSET ?;` +func GetProjectByFollowingFeed(username string, start int, count int, sort string) ([]Project, int, error) { + userID, err := GetUserIdByUsername(username) + if err != nil { + return nil, http.StatusNotFound, err + } - rows, err := DB.Query(query, count, start) + query := fmt.Sprintf(`SELECT p.id, p.name, p.description, COALESCE(p.about_md, ''), p.status, p.likes, + COALESCE((SELECT COUNT(*) FROM projectfollows pf2 WHERE pf2.project_id = p.id), 0), + COALESCE(p.links, '[]'), COALESCE(p.tags, '[]'), COALESCE(p.media, '[]'), p.owner, p.creation_date + FROM projects p + JOIN projectfollows pf ON pf.project_id = p.id + WHERE pf.user_id = $1 + %s + LIMIT $2 OFFSET $3;`, projectOrderBy(sort, "p")) + + rows, err := DB.Query(query, userID, count, start) if err != nil { return nil, http.StatusNotFound, err } - var projects []types.Project defer rows.Close() + projects := []Project{} for rows.Next() { - var project types.Project - var linksJSON, tagsJSON string + var project Project + var linksJSON, tagsJSON, mediaJSON string err := rows.Scan( &project.ID, &project.Name, &project.Description, + &project.AboutMd, &project.Status, &project.Likes, + &project.Saves, &linksJSON, &tagsJSON, + &mediaJSON, &project.Owner, &project.CreationDate, ) if err != nil { if err == sql.ErrNoRows { - return nil, http.StatusOK, nil + return []Project{}, http.StatusOK, nil } return nil, http.StatusInternalServerError, err } @@ -152,52 +384,58 @@ func GetProjectByTimeFeed(start int, count int) ([]types.Project, int, error) { if err := UnmarshalFromJSON(tagsJSON, &project.Tags); err != nil { return nil, http.StatusBadRequest, err } + if err := UnmarshalFromJSON(mediaJSON, &project.Media); err != nil { + return nil, http.StatusBadRequest, err + } + + projects = append(projects, project) } return projects, http.StatusOK, nil } -// GetProjectByLikesFeed retrieves a set of projects for the feed given a type -// it also paginates the results, sorted by most liked -// -// Parameters: -// - start: the int id to start at -// - count: the amount of projects to return -// -// Returns: -// - []types.Project: the list of projects for the feed -// - int: http status code -// - error: An error if the function fails, nil otherwise -func GetProjectByLikesFeed(start int, count int) ([]types.Project, int, error) { - query := `SELECT id, name, description, status, likes, links, tags, owner, creation_date - FROM Project - ORDER BY likes DESC - LIMIT ? OFFSET ?;` +func GetProjectBySavedFeed(username string, start int, count int, sort string) ([]Project, int, error) { + userID, err := GetUserIdByUsername(username) + if err != nil { + return nil, http.StatusNotFound, err + } - rows, err := DB.Query(query, count, start) + query := fmt.Sprintf(`SELECT p.id, p.name, p.description, COALESCE(p.about_md, ''), p.status, p.likes, + COALESCE((SELECT COUNT(*) FROM projectfollows pf2 WHERE pf2.project_id = p.id), 0), + COALESCE(p.links, '[]'), COALESCE(p.tags, '[]'), COALESCE(p.media, '[]'), p.owner, p.creation_date + FROM projects p + JOIN projectfollows pf ON pf.project_id = p.id + WHERE pf.user_id = $1 + %s + LIMIT $2 OFFSET $3;`, projectOrderBy(sort, "p")) + + rows, err := DB.Query(query, userID, count, start) if err != nil { return nil, http.StatusNotFound, err } - var projects []types.Project defer rows.Close() + projects := []Project{} for rows.Next() { - var project types.Project - var linksJSON, tagsJSON string + var project Project + var linksJSON, tagsJSON, mediaJSON string err := rows.Scan( &project.ID, &project.Name, &project.Description, + &project.AboutMd, &project.Status, &project.Likes, + &project.Saves, &linksJSON, &tagsJSON, + &mediaJSON, &project.Owner, &project.CreationDate, ) if err != nil { if err == sql.ErrNoRows { - return nil, http.StatusOK, nil + return []Project{}, http.StatusOK, nil } return nil, http.StatusInternalServerError, err } @@ -208,6 +446,11 @@ func GetProjectByLikesFeed(start int, count int) ([]types.Project, int, error) { if err := UnmarshalFromJSON(tagsJSON, &project.Tags); err != nil { return nil, http.StatusBadRequest, err } + if err := UnmarshalFromJSON(mediaJSON, &project.Media); err != nil { + return nil, http.StatusBadRequest, err + } + + projects = append(projects, project) } return projects, http.StatusOK, nil diff --git a/backend/api/internal/database/notification_queries.go b/backend/api/internal/database/notification_queries.go new file mode 100644 index 0000000..7fe6666 --- /dev/null +++ b/backend/api/internal/database/notification_queries.go @@ -0,0 +1,245 @@ +package database + +import ( + "database/sql" + "fmt" + "net/http" + "strings" + "time" +) + +type Notification struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + ActorID int64 `json:"actor_id"` + ActorName string `json:"actor_name"` + ActorPicture string `json:"actor_picture"` + Type string `json:"type"` + PostID *int64 `json:"post_id"` + ProjectID *int64 `json:"project_id"` + CommentID *int64 `json:"comment_id"` + CreatedAt time.Time `json:"created_at"` + ReadAt *time.Time `json:"read_at"` +} + +type PushToken struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + Token string `json:"token"` + Platform string `json:"platform"` + CreatedAt time.Time `json:"created_at"` +} + +type NotificationInsert struct { + UserID int64 + ActorID int64 + Type string + PostID *int64 + ProjectID *int64 + CommentID *int64 +} + +func CreateNotification(input NotificationInsert) (*Notification, int, error) { + if input.UserID == input.ActorID { + return nil, http.StatusOK, nil + } + + createdAt := time.Now().UTC() + query := `INSERT INTO notifications + (user_id, actor_id, type, post_id, project_id, comment_id, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id;` + + var id int64 + err := DB.QueryRow( + query, + input.UserID, + input.ActorID, + input.Type, + input.PostID, + input.ProjectID, + input.CommentID, + createdAt, + ).Scan(&id) + if err != nil { + return nil, http.StatusInternalServerError, fmt.Errorf("failed to create notification: %v", err) + } + + actor, err := GetUserById(int(input.ActorID)) + if err != nil { + return nil, http.StatusInternalServerError, fmt.Errorf("failed to fetch actor: %v", err) + } + + notification := &Notification{ + ID: id, + UserID: input.UserID, + ActorID: input.ActorID, + ActorName: actor.Username, + ActorPicture: actor.Picture, + Type: input.Type, + PostID: input.PostID, + ProjectID: input.ProjectID, + CommentID: input.CommentID, + CreatedAt: createdAt, + ReadAt: nil, + } + + return notification, http.StatusCreated, nil +} + +func QueryNotificationsByUser(userID int64, start int, count int) ([]Notification, int, error) { + query := `SELECT n.id, n.user_id, n.actor_id, u.username, u.picture, n.type, + n.post_id, n.project_id, n.comment_id, n.created_at, n.read_at + FROM notifications n + JOIN users u ON u.id = n.actor_id + WHERE n.user_id = $1 + ORDER BY n.created_at DESC + LIMIT $2 OFFSET $3;` + + rows, err := DB.Query(query, userID, count, start) + if err != nil { + return nil, http.StatusInternalServerError, err + } + defer rows.Close() + + list := []Notification{} + for rows.Next() { + var item Notification + var postID sql.NullInt64 + var projectID sql.NullInt64 + var commentID sql.NullInt64 + var readAt sql.NullTime + if err := rows.Scan( + &item.ID, + &item.UserID, + &item.ActorID, + &item.ActorName, + &item.ActorPicture, + &item.Type, + &postID, + &projectID, + &commentID, + &item.CreatedAt, + &readAt, + ); err != nil { + return nil, http.StatusInternalServerError, err + } + if postID.Valid { + value := postID.Int64 + item.PostID = &value + } + if projectID.Valid { + value := projectID.Int64 + item.ProjectID = &value + } + if commentID.Valid { + value := commentID.Int64 + item.CommentID = &value + } + if readAt.Valid { + value := readAt.Time + item.ReadAt = &value + } + list = append(list, item) + } + + return list, http.StatusOK, nil +} + +func MarkNotificationRead(userID int64, notificationID int64) (int, error) { + query := `UPDATE notifications SET read_at = $1 WHERE id = $2 AND user_id = $3;` + rowsAffected, err := ExecUpdate(query, time.Now().UTC(), notificationID, userID) + if err != nil { + return http.StatusInternalServerError, err + } + if rowsAffected == 0 { + return http.StatusNotFound, fmt.Errorf("notification not found") + } + return http.StatusOK, nil +} + +func DeleteNotification(userID int64, notificationID int64) (int, error) { + query := `DELETE FROM notifications WHERE id = $1 AND user_id = $2;` + rowsAffected, err := ExecUpdate(query, notificationID, userID) + if err != nil { + return http.StatusInternalServerError, err + } + if rowsAffected == 0 { + return http.StatusNotFound, fmt.Errorf("notification not found") + } + return http.StatusOK, nil +} + +func ClearNotifications(userID int64) (int, error) { + query := `DELETE FROM notifications WHERE user_id = $1;` + _, err := DB.Exec(query, userID) + if err != nil { + return http.StatusInternalServerError, err + } + return http.StatusOK, nil +} + +func DeleteNotificationByReference(userID int64, actorID int64, nType string, postID *int64, projectID *int64) (int, error) { + query := `DELETE FROM notifications WHERE user_id = $1 AND actor_id = $2 AND type = $3 + AND (($4::bigint IS NULL AND post_id IS NULL) OR post_id = $4) + AND (($5::bigint IS NULL AND project_id IS NULL) OR project_id = $5);` + _, err := DB.Exec(query, userID, actorID, nType, postID, projectID) + if err != nil { + return http.StatusInternalServerError, err + } + return http.StatusOK, nil +} + +func GetUnreadNotificationCount(userID int64) (int64, int, error) { + query := `SELECT COUNT(*) FROM notifications WHERE user_id = $1 AND read_at IS NULL;` + row := DB.QueryRow(query, userID) + var count int64 + if err := row.Scan(&count); err != nil { + return 0, http.StatusInternalServerError, err + } + return count, http.StatusOK, nil +} + +func UpsertPushToken(userID int64, token string, platform string) (int, error) { + token = strings.TrimSpace(token) + platform = strings.ToLower(strings.TrimSpace(platform)) + if token == "" { + return http.StatusBadRequest, fmt.Errorf("token is required") + } + query := `INSERT INTO userpushtokens (user_id, token, platform, created_at) + VALUES ($1, $2, $3, $4) + ON CONFLICT(token) DO UPDATE SET user_id = excluded.user_id, platform = excluded.platform;` + _, err := DB.Exec(query, userID, token, platform, time.Now().UTC()) + if err != nil { + return http.StatusInternalServerError, err + } + return http.StatusOK, nil +} + +func DeletePushToken(token string) (int, error) { + query := `DELETE FROM userpushtokens WHERE token = $1;` + _, err := DB.Exec(query, strings.TrimSpace(token)) + if err != nil { + return http.StatusInternalServerError, err + } + return http.StatusOK, nil +} + +func QueryPushTokens(userID int64) ([]PushToken, int, error) { + query := `SELECT id, user_id, token, platform, created_at FROM userpushtokens WHERE user_id = $1;` + rows, err := DB.Query(query, userID) + if err != nil { + return nil, http.StatusInternalServerError, err + } + defer rows.Close() + + list := []PushToken{} + for rows.Next() { + var item PushToken + if err := rows.Scan(&item.ID, &item.UserID, &item.Token, &item.Platform, &item.CreatedAt); err != nil { + return nil, http.StatusInternalServerError, err + } + list = append(list, item) + } + return list, http.StatusOK, nil +} diff --git a/backend/api/internal/database/post_queries.go b/backend/api/internal/database/post_queries.go index 651934d..ff07d6b 100644 --- a/backend/api/internal/database/post_queries.go +++ b/backend/api/internal/database/post_queries.go @@ -6,8 +6,6 @@ import ( "net/http" "strconv" "time" - - "backend/api/internal/types" ) // QueryPosts retrieves a post by its ID from the database. @@ -16,19 +14,26 @@ import ( // - id: The unique identifier of the post to query. // // Returns: -// - *types.Post: The post details if found. +// - *Post: The post details if found. // - error: An error if the query fails. Returns nil for both if no post exists. -func QueryPost(id int) (*types.Post, error) { - query := `SELECT id, user_id, project_id, content, likes, creation_date FROM Posts WHERE id = ?;` +func QueryPost(id int) (*Post, error) { + query := `SELECT id, user_id, project_id, content, COALESCE(media, '[]'), + COALESCE((SELECT COUNT(*) FROM postlikes pl WHERE pl.post_id = posts.id), 0), + COALESCE((SELECT COUNT(*) FROM postsaves ps WHERE ps.post_id = posts.id), 0), + creation_date + FROM posts WHERE id = $1;` row := DB.QueryRow(query, id) - var post types.Post + var post Post + var mediaJSON string err := row.Scan( &post.ID, &post.User, &post.Project, &post.Content, + &mediaJSON, &post.Likes, + &post.Saves, &post.CreationDate, ) if err != nil { @@ -38,6 +43,10 @@ func QueryPost(id int) (*types.Post, error) { return nil, err } + if err := UnmarshalFromJSON(mediaJSON, &post.Media); err != nil { + return nil, err + } + return &post, nil } @@ -49,20 +58,21 @@ func QueryPost(id int) (*types.Post, error) { // Returns: // - int64: The ID of the newly created post. // - error: An error if the operation fails. -func QueryCreatePost(post *types.Post) (int64, error) { +func QueryCreatePost(post *Post) (int64, error) { currentTime := time.Now().UTC() - - query := `INSERT INTO Posts (user_id, project_id, content, likes, creation_date) - VALUES (?, ?, ?, ?, ?);` - - res, err := DB.Exec(query, post.ID, post.User, post.Project, post.Content, post.Likes, currentTime) + mediaJSON, err := MarshalToJSON(post.Media) if err != nil { - return -1, fmt.Errorf("Failed to create post: %v", err) + return -1, err } - lastId, err := res.LastInsertId() + query := `INSERT INTO posts (user_id, project_id, content, media, likes, creation_date) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id;` + + var lastId int64 + err = DB.QueryRow(query, post.User, post.Project, post.Content, string(mediaJSON), post.Likes, currentTime).Scan(&lastId) if err != nil { - return -1, fmt.Errorf("Failed to ensure post was created: %v", err) + return -1, fmt.Errorf("Failed to create post: %v", err) } return lastId, nil @@ -77,7 +87,7 @@ func QueryCreatePost(post *types.Post) (int64, error) { // - int16: http status code indicating the result of the operation. // - error: An error if the operation fails or no post is found. func QueryDeletePost(id int) (int16, error) { - query := `DELETE from Posts WHERE id=?;` + query := `DELETE from posts WHERE id = $1;` res, err := DB.Exec(query, id) if err != nil { return http.StatusBadRequest, fmt.Errorf("Failed to delete post `%v`: %v", id, err) @@ -102,15 +112,16 @@ func QueryDeletePost(id int) (int16, error) { // Returns: // - error: An error if the operation fails or no post is found. func QueryUpdatePost(id int, updatedData map[string]interface{}) error { - query := `UPDATE Posts SET ` + query := `UPDATE posts SET ` var args []interface{} queryParams, args, err := BuildUpdateQuery(updatedData) if err != nil { return fmt.Errorf("Error building query: %v", err) } + query += queryParams - query += queryParams + " WHERE id = ?" + query += fmt.Sprintf(" WHERE id = $%d", len(args)+1) args = append(args, id) rowsAffected, err := ExecUpdate(query, args...) @@ -130,10 +141,14 @@ func QueryUpdatePost(id int, updatedData map[string]interface{}) error { // - id: The unique identifier of the user to query. // // Returns: -// - []types.Post: The post details if found. +// - []Post: The post details if found. // - error: An error if the query fails. Returns nil for both if no post exists. -func QueryPostsByUserId(userId int) ([]types.Post, int, error) { - query := `SELECT id, user_id, project_id, content, likes, creation_date FROM Posts WHERE user_id = ?;` +func QueryPostsByUserId(userId int) ([]Post, int, error) { + query := `SELECT id, user_id, project_id, content, COALESCE(media, '[]'), + COALESCE((SELECT COUNT(*) FROM postlikes pl WHERE pl.post_id = posts.id), 0), + COALESCE((SELECT COUNT(*) FROM postsaves ps WHERE ps.post_id = posts.id), 0), + creation_date + FROM posts WHERE user_id = $1;` rows, err := DB.Query(query, userId) if err != nil { @@ -141,23 +156,14 @@ func QueryPostsByUserId(userId int) ([]types.Post, int, error) { } defer rows.Close() - var posts []types.Post - + posts := []Post{} for rows.Next() { - var post types.Post - err := rows.Scan( - &post.ID, - &post.User, - &post.Project, - &post.Content, - &post.Likes, - &post.CreationDate, - ) - - if err != nil { - if err == sql.ErrNoRows { - return []types.Post{}, http.StatusOK, nil - } + var post Post + var mediaJSON string + if err := rows.Scan(&post.ID, &post.User, &post.Project, &post.Content, &mediaJSON, &post.Likes, &post.Saves, &post.CreationDate); err != nil { + return nil, http.StatusInternalServerError, err + } + if err := UnmarshalFromJSON(mediaJSON, &post.Media); err != nil { return nil, http.StatusInternalServerError, err } posts = append(posts, post) @@ -172,95 +178,86 @@ func QueryPostsByUserId(userId int) ([]types.Post, int, error) { // - id: The unique identifier of the project to query. // // Returns: -// - *types.Post: The post details if found. +// - []Post: The post details if found. // - error: An error if the query fails. Returns nil for both if no post exists. -func QueryPostsByProjectId(projId int) ([]types.Post, int, error) { - query := `SELECT id, user_id, project_id, content, likes, creation_date FROM Posts WHERE project_id = ?;` - - rows, err := DB.Query(query, projId) +func QueryPostsByProjectId(projectId int) ([]Post, int, error) { + query := `SELECT id, user_id, project_id, content, COALESCE(media, '[]'), + COALESCE((SELECT COUNT(*) FROM postlikes pl WHERE pl.post_id = posts.id), 0), + COALESCE((SELECT COUNT(*) FROM postsaves ps WHERE ps.post_id = posts.id), 0), + creation_date + FROM posts WHERE project_id = $1;` + + rows, err := DB.Query(query, projectId) if err != nil { return nil, http.StatusNotFound, err } defer rows.Close() - var posts []types.Post = []types.Post{} - + posts := []Post{} for rows.Next() { - var post types.Post - err := rows.Scan( - &post.ID, - &post.User, - &post.Project, - &post.Content, - &post.Likes, - &post.CreationDate, - ) - - if err != nil { - if err == sql.ErrNoRows { - return []types.Post{}, http.StatusOK, nil - } + var post Post + var mediaJSON string + if err := rows.Scan(&post.ID, &post.User, &post.Project, &post.Content, &mediaJSON, &post.Likes, &post.Saves, &post.CreationDate); err != nil { + return nil, http.StatusInternalServerError, err + } + if err := UnmarshalFromJSON(mediaJSON, &post.Media); err != nil { return nil, http.StatusInternalServerError, err } posts = append(posts, post) } + return posts, http.StatusOK, nil } -// CreatePostLike creates a like relationship between a user and a post. -// -// Parameters: -// - username: The username of the user creating the like. -// - strPostID: The ID of the project to like (as a string, converted internally). -// -// Returns: -// - int: HTTP-like status code indicating the result of the operation. -// - error: An error if the operation fails or the user is not liking the post. -func CreatePostLike(username string, strPostId string) (int, error) { - // get user ID from username, implicitly checks if user exists - user_id, err := GetUserIdByUsername(username) +func CreatePostLike(username string, postId string) (int, error) { + userID, err := GetUserIdByUsername(username) if err != nil { return http.StatusInternalServerError, fmt.Errorf("An error occurred getting id for username: %v", err) } - // parse post ID - postId, err := strconv.Atoi(strPostId) + parsedPostID, err := strconv.Atoi(postId) if err != nil { - return http.StatusInternalServerError, fmt.Errorf("An error occurred parsing post_id id: %v", err) + return http.StatusInternalServerError, fmt.Errorf("An error occurred parsing post id: %v", err) } - // verify post exists - post, err := QueryPost(postId) + existingPost, err := QueryPost(parsedPostID) if err != nil { - return http.StatusInternalServerError, fmt.Errorf("An error occurred verifying the post exists: %v", err) - } else if post == nil { - return http.StatusNotFound, fmt.Errorf("Post ID %d does not exist", postId) + return http.StatusInternalServerError, fmt.Errorf("Error querying for existing post: %v", err) + } + if existingPost == nil { + return http.StatusNotFound, fmt.Errorf("Post with id %v does not exist", parsedPostID) } - // check if the like already exists - var exists bool - query := `SELECT EXISTS ( - SELECT 1 FROM PostLikes WHERE user_id = ? AND post_id = ? - )` - err = DB.QueryRow(query, user_id, postId).Scan(&exists) + tx, err := DB.Begin() if err != nil { - return http.StatusInternalServerError, fmt.Errorf("An error occurred checking like existence: %v", err) - } - if exists { - // like already exists, but we return success to keep it idempotent - return http.StatusOK, nil + return -1, fmt.Errorf("failed to begin transaction: %v", err) } - // insert the like - insertQuery := `INSERT INTO PostLikes (user_id, post_id) VALUES (?, ?)` - _, err = DB.Exec(insertQuery, user_id, postId) + defer func() { + if err != nil { + tx.Rollback() + } else { + tx.Commit() + } + }() + + insertQuery := `INSERT INTO postlikes (user_id, post_id) VALUES ($1, $2) ON CONFLICT (user_id, post_id) DO NOTHING` + result, err := tx.Exec(insertQuery, userID, parsedPostID) if err != nil { return http.StatusInternalServerError, fmt.Errorf("Failed to insert post like: %v", err) } - // update the likes column - updateQuery := `UPDATE Posts SET likes = likes + 1 WHERE id = ?` - _, err = DB.Exec(updateQuery, postId) + rowsAffected, err := result.RowsAffected() + if err != nil { + return http.StatusInternalServerError, fmt.Errorf("Failed to check rows affected: %v", err) + } + + if rowsAffected == 0 { + return http.StatusOK, nil + } + + updateQuery := `UPDATE posts SET likes = likes + 1 WHERE id = $1` + _, err = tx.Exec(updateQuery, parsedPostID) if err != nil { return http.StatusInternalServerError, fmt.Errorf("Failed to update likes count: %v", err) } @@ -268,55 +265,55 @@ func CreatePostLike(username string, strPostId string) (int, error) { return http.StatusCreated, nil } -// RemovePostLike deletes a like relationship between a user and a post. -// -// Parameters: -// - username: The username of the user removing the like. -// - strPostID: The ID of the post to unlike (as a string, converted internally). -// -// Returns: -// - int: HTTP-like status code indicating the result of the operation. -// - error: An error if the operation fails or the user is not liking the post. -func RemovePostLike(username string, strPostId string) (int, error) { - // get user ID - user_id, err := GetUserIdByUsername(username) +func RemovePostLike(username string, postId string) (int, error) { + userID, err := GetUserIdByUsername(username) if err != nil { return http.StatusInternalServerError, fmt.Errorf("An error occurred getting id for username: %v", err) } - // parse post ID - postId, err := strconv.Atoi(strPostId) + parsedPostID, err := strconv.Atoi(postId) + if err != nil { + return http.StatusInternalServerError, fmt.Errorf("An error occurred parsing post id: %v", err) + } + + existingPost, err := QueryPost(parsedPostID) if err != nil { - return http.StatusInternalServerError, fmt.Errorf("An error occurred parsing username id: %v", err) + return http.StatusInternalServerError, fmt.Errorf("Error querying for existing post: %v", err) + } + if existingPost == nil { + return http.StatusNotFound, fmt.Errorf("Post with id %v does not exist", parsedPostID) } - // verify post exists - _, err = QueryPost(postId) + tx, err := DB.Begin() if err != nil { - return http.StatusInternalServerError, fmt.Errorf("An error occurred verifying the post exists: %v", err) + return -1, fmt.Errorf("failed to begin transaction: %v", err) } - // perform the delete operation - deleteQuery := `DELETE FROM PostLikes WHERE user_id = ? AND post_id = ?` - result, err := DB.Exec(deleteQuery, user_id, postId) + defer func() { + if err != nil { + tx.Rollback() + } else { + tx.Commit() + } + }() + + deleteQuery := `DELETE FROM postlikes WHERE user_id = $1 AND post_id = $2` + result, err := tx.Exec(deleteQuery, userID, parsedPostID) if err != nil { return http.StatusInternalServerError, fmt.Errorf("Failed to delete post like: %v", err) } - // check if any rows were actually deleted rowsAffected, err := result.RowsAffected() if err != nil { return http.StatusInternalServerError, fmt.Errorf("Failed to check rows affected: %v", err) } if rowsAffected == 0 { - // if no rows were deleted, return success to keep idempotency return http.StatusNoContent, nil } - // update the likes column - updateQuery := `UPDATE Posts SET likes = likes - 1 WHERE id = ?` - _, err = DB.Exec(updateQuery, postId) + updateQuery := `UPDATE posts SET likes = GREATEST(likes - 1, 0) WHERE id = $1` + _, err = tx.Exec(updateQuery, parsedPostID) if err != nil { return http.StatusInternalServerError, fmt.Errorf("Failed to update likes count: %v", err) } @@ -324,47 +321,13 @@ func RemovePostLike(username string, strPostId string) (int, error) { return http.StatusOK, nil } -// QueryPostLike queries for a like relationship between a user and a post. -// -// Parameters: -// - username: The username of the user removing the like. -// - postID: The ID of the post to unlike (as a string, converted internally). -// -// Returns: -// - int: HTTP-like status code indicating the result of the operation. -// - error: An error if the operation fails or. -func QueryPostLike(username string, strPostId string) (int, bool, error) { - // get user ID from username, implicitly checks if user exists - user_id, err := GetUserIdByUsername(username) - if err != nil { - return http.StatusInternalServerError, false, fmt.Errorf("An error occurred getting id for username: %v", err) - } - - // parse post ID - postId, err := strconv.Atoi(strPostId) - if err != nil { - return http.StatusInternalServerError, false, fmt.Errorf("An error occurred parsing post_id: %v", err) - } - - // verify post exists - _, err = QueryPost(postId) - if err != nil { - return http.StatusInternalServerError, false, fmt.Errorf("An error occurred verifying the post exists: %v", err) - } - - // check if the like already exists +func QueryPostLike(username string, postId string) (int, bool, error) { + query := `SELECT EXISTS(SELECT 1 FROM postlikes WHERE user_id = (SELECT id FROM users WHERE username = $1) AND post_id = $2);` var exists bool - query := `SELECT EXISTS ( - SELECT 1 FROM PostLikes WHERE user_id = ? AND post_id = ? - )` - err = DB.QueryRow(query, user_id, postId).Scan(&exists) + err := DB.QueryRow(query, username, postId).Scan(&exists) if err != nil { - return http.StatusInternalServerError, false, fmt.Errorf("An error occurred checking like existence: %v", err) - } - if exists { - return http.StatusOK, true, nil - } else { - return http.StatusOK, false, nil + return http.StatusInternalServerError, false, err } - + return http.StatusOK, exists, nil } + diff --git a/backend/api/internal/database/post_saves_queries.go b/backend/api/internal/database/post_saves_queries.go new file mode 100644 index 0000000..f9409d5 --- /dev/null +++ b/backend/api/internal/database/post_saves_queries.go @@ -0,0 +1,86 @@ +package database + +import ( + "fmt" + "net/http" + "strconv" +) + +func QuerySavePost(username string, postID string) (int, error) { + userID, err := GetUserIdByUsername(username) + if err != nil { + return http.StatusInternalServerError, fmt.Errorf("An error occurred getting id for username: %v", err) + } + + intPostID, err := strconv.Atoi(postID) + if err != nil { + return http.StatusInternalServerError, fmt.Errorf("An error occurred parsing post id: %v", postID) + } + + post, err := QueryPost(intPostID) + if err != nil { + return http.StatusInternalServerError, fmt.Errorf("Error querying for existing post: %v", err) + } + if post == nil { + return http.StatusNotFound, fmt.Errorf("Post with id %v does not exist", intPostID) + } + + query := `INSERT INTO postsaves (user_id, post_id) VALUES ($1, $2) ON CONFLICT DO NOTHING;` + rowsAffected, err := ExecUpdate(query, userID, intPostID) + if err != nil { + return http.StatusInternalServerError, fmt.Errorf("An error occurred saving post: %v", err) + } + if rowsAffected == 0 { + return http.StatusConflict, fmt.Errorf("Post already saved") + } + + return http.StatusOK, nil +} + +func QueryUnsavePost(username string, postID string) (int, error) { + userID, err := GetUserIdByUsername(username) + if err != nil { + return http.StatusInternalServerError, fmt.Errorf("An error occurred getting id for username: %v", err) + } + + intPostID, err := strconv.Atoi(postID) + if err != nil { + return http.StatusInternalServerError, fmt.Errorf("An error occurred parsing post id: %v", postID) + } + + query := `DELETE FROM postsaves WHERE post_id = $1 AND user_id = $2;` + rowsAffected, err := ExecUpdate(query, intPostID, userID) + if err != nil { + return http.StatusInternalServerError, fmt.Errorf("An error occurred unsaving post: %v", err) + } + if rowsAffected == 0 { + return http.StatusConflict, fmt.Errorf("Post is not saved") + } + + return http.StatusOK, nil +} + +func QuerySavedPostsByUser(username string) ([]int, int, error) { + userID, err := GetUserIdByUsername(username) + if err != nil { + return nil, http.StatusNotFound, fmt.Errorf("Cannot find user with username '%v'", username) + } + + query := `SELECT post_id FROM postsaves WHERE user_id = $1 ORDER BY post_id DESC;` + rows, err := DB.Query(query, userID) + if err != nil { + return nil, http.StatusInternalServerError, err + } + defer rows.Close() + + list := []int{} + for rows.Next() { + var postID int + if err := rows.Scan(&postID); err != nil { + return nil, http.StatusInternalServerError, err + } + list = append(list, postID) + } + + return list, http.StatusOK, nil +} diff --git a/backend/api/internal/database/project_queries.go b/backend/api/internal/database/project_queries.go index 4c25f7b..f5ef401 100644 --- a/backend/api/internal/database/project_queries.go +++ b/backend/api/internal/database/project_queries.go @@ -4,11 +4,8 @@ import ( "database/sql" "fmt" "net/http" - "slices" "strconv" "time" - - "backend/api/internal/types" ) // QueryProject retrieves a project by its ID from the database. @@ -17,22 +14,28 @@ import ( // - id: The unique identifier of the project to query. // // Returns: -// - *types.Project: The project details if found. +// - *Project: The project details if found. // - error: An error if the query fails. Returns nil for both if no project exists. -func QueryProject(id int) (*types.Project, error) { - query := `SELECT id, name, description, status, likes, links, tags, owner, creation_date FROM Projects WHERE id = ?;` +func QueryProject(id int) (*Project, error) { + query := `SELECT id, name, description, COALESCE(about_md, ''), status, likes, + COALESCE((SELECT COUNT(*) FROM projectfollows pf WHERE pf.project_id = projects.id), 0), + COALESCE(links, '[]'), COALESCE(tags, '[]'), COALESCE(media, '[]'), owner, creation_date + FROM projects WHERE id = $1;` row := DB.QueryRow(query, id) - var project types.Project - var linksJSON, tagsJSON string + var project Project + var linksJSON, tagsJSON, mediaJSON string err := row.Scan( &project.ID, &project.Name, &project.Description, + &project.AboutMd, &project.Status, &project.Likes, + &project.Saves, &linksJSON, &tagsJSON, + &mediaJSON, &project.Owner, &project.CreationDate, ) @@ -49,6 +52,9 @@ func QueryProject(id int) (*types.Project, error) { if err := UnmarshalFromJSON(tagsJSON, &project.Tags); err != nil { return nil, err } + if err := UnmarshalFromJSON(mediaJSON, &project.Media); err != nil { + return nil, err + } return &project, nil } @@ -59,28 +65,34 @@ func QueryProject(id int) (*types.Project, error) { // - id: The unique identifier of the user to query projects on. // // Returns: -// - *[]types.Project: A list of the projects' details if found. +// - *[]Project: A list of the projects' details if found. // - error: An error if the query fails. Returns nil for both if no project exists. -func QueryProjectsByUserId(userId int) ([]types.Project, int, error) { - query := `SELECT id, name, description, status, likes, links, tags, owner, creation_date FROM Projects WHERE owner = ?;` +func QueryProjectsByUserId(userId int) ([]Project, int, error) { + query := `SELECT id, name, description, COALESCE(about_md, ''), status, likes, + COALESCE((SELECT COUNT(*) FROM projectfollows pf WHERE pf.project_id = projects.id), 0), + COALESCE(links, '[]'), COALESCE(tags, '[]'), COALESCE(media, '[]'), owner, creation_date + FROM projects WHERE owner = $1;` rows, err := DB.Query(query, userId) if err != nil { return nil, http.StatusNotFound, err } - var projects []types.Project + projects := []Project{} defer rows.Close() for rows.Next() { - var project types.Project - var linksJSON, tagsJSON string + var project Project + var linksJSON, tagsJSON, mediaJSON string err := rows.Scan( &project.ID, &project.Name, &project.Description, + &project.AboutMd, &project.Status, &project.Likes, + &project.Saves, &linksJSON, &tagsJSON, + &mediaJSON, &project.Owner, &project.CreationDate, ) @@ -97,6 +109,11 @@ func QueryProjectsByUserId(userId int) ([]types.Project, int, error) { if err := UnmarshalFromJSON(tagsJSON, &project.Tags); err != nil { return nil, http.StatusBadRequest, err } + if err := UnmarshalFromJSON(mediaJSON, &project.Media); err != nil { + return nil, http.StatusBadRequest, err + } + + projects = append(projects, project) } return projects, http.StatusOK, nil @@ -110,7 +127,7 @@ func QueryProjectsByUserId(userId int) ([]types.Project, int, error) { // Returns: // - int64: The ID of the newly created project. // - error: An error if the operation fails. -func QueryCreateProject(proj *types.Project) (int64, error) { +func QueryCreateProject(proj *Project) (int64, error) { linksJSON, err := MarshalToJSON(proj.Links) if err != nil { return -1, err @@ -121,21 +138,22 @@ func QueryCreateProject(proj *types.Project) (int64, error) { return -1, err } + mediaJSON, err := MarshalToJSON(proj.Media) + if err != nil { + return -1, err + } + currentTime := time.Now().UTC() - query := `INSERT INTO Projects (name, description, status, links, tags, owner, creation_date) - VALUES (?, ?, ?, ?, ?, ?, ?);` + query := `INSERT INTO projects (name, description, about_md, status, links, tags, media, owner, creation_date) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id;` - res, err := DB.Exec(query, proj.Name, proj.Description, proj.Status, string(linksJSON), string(tagsJSON), proj.Owner, currentTime) + var lastId int64 + err = DB.QueryRow(query, proj.Name, proj.Description, proj.AboutMd, proj.Status, string(linksJSON), string(tagsJSON), string(mediaJSON), proj.Owner, currentTime).Scan(&lastId) if err != nil { return -1, fmt.Errorf("Failed to create project '%v': %v", proj.Name, err) } - lastId, err := res.LastInsertId() - if err != nil { - return -1, fmt.Errorf("Failed to ensure project was created: %v", err) - } - return lastId, nil } @@ -148,17 +166,51 @@ func QueryCreateProject(proj *types.Project) (int64, error) { // - int16: http status code indicating the result of the operation. // - error: An error if the operation fails or no project is found. func QueryDeleteProject(id int) (int16, error) { - query := `DELETE from Projects WHERE id=?;` - res, err := DB.Exec(query, id) + tx, err := DB.Begin() if err != nil { - return http.StatusBadRequest, fmt.Errorf("Failed to delete project `%v`: %v", id, err) + return http.StatusInternalServerError, fmt.Errorf("Failed to start delete transaction: %v", err) + } + + rollback := func(original error) (int16, error) { + if err := tx.Rollback(); err != nil { + return http.StatusInternalServerError, fmt.Errorf("Failed to rollback delete: %v", err) + } + return http.StatusBadRequest, original + } + + _, err = tx.Exec( + `DELETE FROM comments WHERE id IN ( + SELECT pc.comment_id + FROM postcomments pc + JOIN posts p ON pc.post_id = p.id + WHERE p.project_id = $1 + );`, + id, + ) + if err != nil { + return rollback(fmt.Errorf("Failed to delete project comments for `%v`: %v", id, err)) + } + + _, err = tx.Exec(`DELETE FROM posts WHERE project_id = $1;`, id) + if err != nil { + return rollback(fmt.Errorf("Failed to delete project posts for `%v`: %v", id, err)) + } + + res, err := tx.Exec(`DELETE FROM projects WHERE id = $1;`, id) + if err != nil { + return rollback(fmt.Errorf("Failed to delete project `%v`: %v", id, err)) } rowsAffected, err := res.RowsAffected() + if err != nil { + return rollback(fmt.Errorf("Failed to fetch affected rows: %v", err)) + } if rowsAffected == 0 { - return http.StatusNotFound, fmt.Errorf("Deletion did not affect any records") - } else if err != nil { - return http.StatusInternalServerError, fmt.Errorf("Failed to fetch affected rows: %v", err) + return rollback(fmt.Errorf("Deletion did not affect any records")) + } + + if err := tx.Commit(); err != nil { + return http.StatusInternalServerError, fmt.Errorf("Failed to commit delete: %v", err) } return http.StatusOK, nil @@ -173,15 +225,16 @@ func QueryDeleteProject(id int) (int16, error) { // Returns: // - error: An error if the operation fails or no project is found. func QueryUpdateProject(id int, updatedData map[string]interface{}) error { - query := `UPDATE Projects SET ` + query := `UPDATE projects SET ` var args []interface{} queryParams, args, err := BuildUpdateQuery(updatedData) if err != nil { return fmt.Errorf("Error building query: %v", err) } + query += queryParams - query += queryParams + " WHERE id = ?" + query += fmt.Sprintf(" WHERE id = $%d", len(args)+1) args = append(args, id) rowsAffected, err := ExecUpdate(query, args...) @@ -207,9 +260,9 @@ func QueryUpdateProject(id int, updatedData map[string]interface{}) error { func QueryGetProjectFollowers(projectID int) ([]int, int, error) { query := ` SELECT u.id - FROM Users u - JOIN ProjectFollows pf ON u.id = pf.user_id - WHERE pf.project_id = ?` + FROM users u + JOIN projectfollows pf ON u.id = pf.user_id + WHERE pf.project_id = $1` return getProjectFollowersOrFollowing(query, projectID) } @@ -226,9 +279,9 @@ func QueryGetProjectFollowers(projectID int) ([]int, int, error) { func QueryGetProjectFollowersUsernames(projectID int) ([]string, int, error) { query := ` SELECT u.username - FROM Users u - JOIN ProjectFollows pf ON u.id = pf.user_id - WHERE pf.project_id = ?` + FROM users u + JOIN projectfollows pf ON u.id = pf.user_id + WHERE pf.project_id = $1` return getProjectFollowersOrFollowingUsernames(query, projectID) } @@ -250,9 +303,9 @@ func QueryGetProjectFollowing(username string) ([]int, int, error) { query := ` SELECT p.id - FROM Projects p - JOIN ProjectFollows pf ON p.id = pf.project_id - WHERE pf.user_id = ?` + FROM projects p + JOIN projectfollows pf ON p.id = pf.project_id + WHERE pf.user_id = $1` return getProjectFollowersOrFollowing(query, userID) } @@ -274,13 +327,137 @@ func QueryGetProjectFollowingNames(username string) ([]string, int, error) { query := ` SELECT p.name - FROM Projects p - JOIN ProjectFollows pf ON p.id = pf.project_id - WHERE pf.user_id = ?` + FROM projects p + JOIN projectfollows pf ON p.id = pf.project_id + WHERE pf.user_id = $1` return getProjectFollowersOrFollowingUsernames(query, userID) } +// QueryProjectsByBuilderId retrieves projects a user owns or can build. +func QueryProjectsByBuilderId(userId int) ([]Project, int, error) { + query := `SELECT id, name, description, COALESCE(about_md, ''), status, likes, + COALESCE((SELECT COUNT(*) FROM projectfollows pf WHERE pf.project_id = projects.id), 0), + COALESCE(links, '[]'), COALESCE(tags, '[]'), COALESCE(media, '[]'), owner, creation_date + FROM projects + WHERE owner = $1 OR id IN ( + SELECT project_id FROM projectbuilders WHERE user_id = $2 + );` + rows, err := DB.Query(query, userId, userId) + if err != nil { + return nil, http.StatusNotFound, err + } + defer rows.Close() + projects := []Project{} + + for rows.Next() { + var project Project + var linksJSON, tagsJSON, mediaJSON string + err := rows.Scan( + &project.ID, + &project.Name, + &project.Description, + &project.AboutMd, + &project.Status, + &project.Likes, + &project.Saves, + &linksJSON, + &tagsJSON, + &mediaJSON, + &project.Owner, + &project.CreationDate, + ) + if err != nil { + if err == sql.ErrNoRows { + return nil, http.StatusOK, nil + } + return nil, http.StatusInternalServerError, err + } + + if err := UnmarshalFromJSON(linksJSON, &project.Links); err != nil { + return nil, http.StatusBadRequest, err + } + if err := UnmarshalFromJSON(tagsJSON, &project.Tags); err != nil { + return nil, http.StatusBadRequest, err + } + if err := UnmarshalFromJSON(mediaJSON, &project.Media); err != nil { + return nil, http.StatusBadRequest, err + } + + projects = append(projects, project) + } + + return projects, http.StatusOK, nil +} + +// QueryProjectBuilders retrieves usernames of project builders. +func QueryProjectBuilders(projectId int) ([]string, int, error) { + query := `SELECT u.username + FROM users u + JOIN projectbuilders pb ON pb.user_id = u.id + WHERE pb.project_id = $1;` + rows, err := DB.Query(query, projectId) + if err != nil { + return nil, http.StatusNotFound, err + } + defer rows.Close() + + builders := []string{} + for rows.Next() { + var username string + if err := rows.Scan(&username); err != nil { + return nil, http.StatusInternalServerError, err + } + builders = append(builders, username) + } + if err := rows.Err(); err != nil { + return nil, http.StatusInternalServerError, err + } + + return builders, http.StatusOK, nil +} + +// QueryIsProjectBuilder checks if a user is a builder for the project. +func QueryIsProjectBuilder(projectId int, userId int64) (bool, error) { + query := `SELECT 1 FROM projectbuilders WHERE project_id = $1 AND user_id = $2 LIMIT 1;` + row := DB.QueryRow(query, projectId, userId) + var exists int + if err := row.Scan(&exists); err != nil { + if err == sql.ErrNoRows { + return false, nil + } + return false, err + } + return true, nil +} + +// QueryAddProjectBuilder adds a builder to a project. +func QueryAddProjectBuilder(projectId int, userId int64) (int, error) { + query := `INSERT INTO projectbuilders (project_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING;` + _, err := DB.Exec(query, projectId, userId) + if err != nil { + return http.StatusInternalServerError, err + } + return http.StatusOK, nil +} + +// QueryRemoveProjectBuilder removes a builder from a project. +func QueryRemoveProjectBuilder(projectId int, userId int64) (int, error) { + query := `DELETE FROM projectbuilders WHERE project_id = $1 AND user_id = $2;` + res, err := DB.Exec(query, projectId, userId) + if err != nil { + return http.StatusInternalServerError, err + } + rowsAffected, err := res.RowsAffected() + if err != nil { + return http.StatusInternalServerError, err + } + if rowsAffected == 0 { + return http.StatusNotFound, fmt.Errorf("builder not found") + } + return http.StatusOK, nil +} + // getProjectFollowersOrFollowing is a helper function for retrieving follower or following IDs. // // Parameters: @@ -366,33 +543,22 @@ func CreateNewProjectFollow(username string, projectID string) (int, error) { if err != nil { return http.StatusInternalServerError, fmt.Errorf("An error occurred parsing project id: %v", projectID) } - currFollowing, httpCode, err := QueryGetProjectFollowers(userID) + existingProj, err := QueryProject(intProjectID) if err != nil { - return httpCode, fmt.Errorf("Cannot retrieve user's following list: %v", err) + return http.StatusInternalServerError, fmt.Errorf("Error querying for existing project: %v", err) } - existingProj, err := QueryProject(intProjectID) - if err != nil { - return http.StatusInternalServerError, fmt.Errorf("Error querying for existing project: %v", err) - } - - if existingProj == nil { - return http.StatusNotFound, fmt.Errorf("Project with id %v does not exist", intProjectID) - } - - if slices.Contains(currFollowing, intProjectID) { - return http.StatusConflict, fmt.Errorf("User is already following this project") + if existingProj == nil { + return http.StatusNotFound, fmt.Errorf("Project with id %v does not exist", intProjectID) } - query := `INSERT INTO ProjectFollows (user_id, project_id) VALUES (?, ?)` + query := `INSERT INTO projectfollows (user_id, project_id) VALUES ($1, $2) ON CONFLICT DO NOTHING` rowsAffected, err := ExecUpdate(query, userID, projectID) if err != nil { return http.StatusInternalServerError, fmt.Errorf("An error occurred adding project follow: %v", err) } - if rowsAffected == 0 { - return http.StatusInternalServerError, fmt.Errorf("Failed to add the follow relationship") - } + _ = rowsAffected return http.StatusOK, nil } @@ -417,33 +583,22 @@ func RemoveProjectFollow(username string, projectID string) (int, error) { return http.StatusInternalServerError, fmt.Errorf("An error occurred parsing project id: %v", projectID) } - existingProj, err := QueryProject(intProjectID) - if err != nil { - return http.StatusInternalServerError, fmt.Errorf("Error querying for existing project: %v", err) - } - - if existingProj == nil { - return http.StatusNotFound, fmt.Errorf("Project with id %v does not exist", intProjectID) - } - - currFollowing, httpCode, err := QueryGetProjectFollowers(userID) + existingProj, err := QueryProject(intProjectID) if err != nil { - return httpCode, fmt.Errorf("Error retrieving user's following list: %v", err) + return http.StatusInternalServerError, fmt.Errorf("Error querying for existing project: %v", err) } - if !slices.Contains(currFollowing, intProjectID) { - return http.StatusConflict, fmt.Errorf("User is not following this project") + if existingProj == nil { + return http.StatusNotFound, fmt.Errorf("Project with id %v does not exist", intProjectID) } - query := `DELETE FROM ProjectFollows WHERE user_id = ? AND project_id = ?` + query := `DELETE FROM projectfollows WHERE user_id = $1 AND project_id = $2` rowsAffected, err := ExecUpdate(query, userID, projectID) if err != nil { return http.StatusInternalServerError, fmt.Errorf("An error occurred removing project follow: %v", err) } - if rowsAffected == 0 { - return http.StatusConflict, fmt.Errorf("No such follow relationship exists") - } + _ = rowsAffected return http.StatusOK, nil } @@ -471,19 +626,19 @@ func CreateProjectLike(username string, strProjId string) (int, error) { } // verify project exists - existingProj, err := QueryProject(projId) - if err != nil { - return http.StatusInternalServerError, fmt.Errorf("Error querying for existing project: %v", err) - } + existingProj, err := QueryProject(projId) + if err != nil { + return http.StatusInternalServerError, fmt.Errorf("Error querying for existing project: %v", err) + } - if existingProj == nil { - return http.StatusNotFound, fmt.Errorf("Project with id %v does not exist", projId) - } + if existingProj == nil { + return http.StatusNotFound, fmt.Errorf("Project with id %v does not exist", projId) + } // check if the like already exists var exists bool query := `SELECT EXISTS ( - SELECT 1 FROM ProjectLikes WHERE user_id = ? AND project_id = ? + SELECT 1 FROM projectlikes WHERE user_id = $1 AND project_id = $2 )` err = DB.QueryRow(query, user_id, projId).Scan(&exists) if err != nil { @@ -494,16 +649,29 @@ func CreateProjectLike(username string, strProjId string) (int, error) { return http.StatusOK, nil } + tx, err := DB.Begin() + if err != nil { + return -1, fmt.Errorf("failed to begin transaction: %v", err) + } + + defer func() { + if err != nil { + tx.Rollback() + } else { + tx.Commit() + } + }() + // insert the like - insertQuery := `INSERT INTO ProjectLikes (user_id, project_id) VALUES (?, ?)` - _, err = DB.Exec(insertQuery, user_id, projId) + insertQuery := `INSERT INTO projectlikes (user_id, project_id) VALUES ($1, $2)` + _, err = tx.Exec(insertQuery, user_id, projId) if err != nil { return http.StatusInternalServerError, fmt.Errorf("Failed to insert project like: %v", err) } // update the likes column - updateQuery := `UPDATE Projects SET likes = likes + 1 WHERE id = ?` - _, err = DB.Exec(updateQuery, projId) + updateQuery := `UPDATE projects SET likes = likes + 1 WHERE id = $1` + _, err = tx.Exec(updateQuery, projId) if err != nil { return http.StatusInternalServerError, fmt.Errorf("Failed to update likes count: %v", err) } @@ -534,18 +702,31 @@ func RemoveProjectLike(username string, strProjId string) (int, error) { } // verify project exists - existingProj, err := QueryProject(projId) - if err != nil { - return http.StatusInternalServerError, fmt.Errorf("Error querying for existing project: %v", err) - } + existingProj, err := QueryProject(projId) + if err != nil { + return http.StatusInternalServerError, fmt.Errorf("Error querying for existing project: %v", err) + } - if existingProj == nil { - return http.StatusNotFound, fmt.Errorf("Project with id %v does not exist", projId) - } + if existingProj == nil { + return http.StatusNotFound, fmt.Errorf("Project with id %v does not exist", projId) + } + + tx, err := DB.Begin() + if err != nil { + return -1, fmt.Errorf("failed to begin transaction: %v", err) + } + + defer func() { + if err != nil { + tx.Rollback() + } else { + tx.Commit() + } + }() // perform the delete operation - deleteQuery := `DELETE FROM ProjectLikes WHERE user_id = ? AND project_id = ?` - result, err := DB.Exec(deleteQuery, user_id, projId) + deleteQuery := `DELETE FROM projectlikes WHERE user_id = $1 AND project_id = $2` + result, err := tx.Exec(deleteQuery, user_id, projId) if err != nil { return http.StatusInternalServerError, fmt.Errorf("Failed to delete project like: %v", err) } @@ -562,8 +743,8 @@ func RemoveProjectLike(username string, strProjId string) (int, error) { } // update the likes column - updateQuery := `UPDATE Projects SET likes = likes - 1 WHERE id = ?` - _, err = DB.Exec(updateQuery, projId) + updateQuery := `UPDATE projects SET likes = likes - 1 WHERE id = $1` + _, err = tx.Exec(updateQuery, projId) if err != nil { return http.StatusInternalServerError, fmt.Errorf("Failed to update likes count: %v", err) } @@ -594,19 +775,19 @@ func QueryProjectLike(username string, strProjId string) (int, bool, error) { } // verify project exists - existingProj, err := QueryProject(projId) + existingProj, err := QueryProject(projId) if err != nil { return http.StatusInternalServerError, false, fmt.Errorf("An error occurred verifying the project exists: %v", err) } - if existingProj == nil { - return http.StatusNotFound, false, fmt.Errorf("Project with id %v does not exist", projId) - } + if existingProj == nil { + return http.StatusNotFound, false, fmt.Errorf("Project with id %v does not exist", projId) + } // check if the like already exists var exists bool query := `SELECT EXISTS ( - SELECT 1 FROM ProjectLikes WHERE user_id = ? AND project_id = ? + SELECT 1 FROM projectlikes WHERE user_id = $1 AND project_id = $2 )` err = DB.QueryRow(query, user_id, projId).Scan(&exists) if err != nil { diff --git a/backend/api/internal/database/types.go b/backend/api/internal/database/types.go new file mode 100644 index 0000000..5f1bd6d --- /dev/null +++ b/backend/api/internal/database/types.go @@ -0,0 +1,31 @@ +package database + +import ( + "time" +) + +type Post struct { + ID int64 `json:"id"` + User int64 `json:"user" binding:"required"` + Project int64 `json:"project" binding:"required"` + Likes int64 `json:"likes"` + Saves int64 `json:"saves"` + Content string `json:"content" binding:"required"` + Media []string `json:"media"` + CreationDate time.Time `json:"created_on"` +} + +type Project struct { + ID int64 `json:"id"` + Owner int64 `json:"owner" binding:"required"` + Name string `json:"name" binding:"required"` + Description string `json:"description" binding:"required"` + AboutMd string `json:"about_md"` + Status int16 `json:"status"` + Likes int64 `json:"likes"` + Saves int64 `json:"saves"` + Tags []string `json:"tags"` + Links []string `json:"links"` + Media []string `json:"media"` + CreationDate time.Time `json:"creation_date"` +} diff --git a/backend/api/internal/database/user_queries.go b/backend/api/internal/database/user_queries.go index c0f0ec7..5259057 100644 --- a/backend/api/internal/database/user_queries.go +++ b/backend/api/internal/database/user_queries.go @@ -4,428 +4,535 @@ import ( "database/sql" "encoding/json" "fmt" - "net/http" - "slices" - "time" - - "backend/api/internal/types" + "log" ) -// GetUsernameById retrieves the username associated with the given user ID. -// -// Parameters: -// - id: The unique identifier of the user. -// -// Returns: -// - string: The username if found. -// - error: An error if the query fails. Returns nil for both if no user exists. -func GetUsernameById(id int64) (string, error) { - query := `SELECT username FROM Users WHERE id = ?;` - - row := DB.QueryRow(query, id) - - var retrievedUserName string - err := row.Scan(&retrievedUserName) - if err != nil { - if err == sql.ErrNoRows { - return "", nil - } - return "", err - } +type ApiUser struct { + Id int `json:"id"` + Username string `json:"username"` + Picture string `json:"picture"` + Bio string `json:"bio"` + Links map[string]interface{} `json:"links"` + Settings map[string]interface{} `json:"settings"` + CreationDate string `json:"creation_date"` +} - return retrievedUserName, nil +type UserLoginInfo struct { + Username string `json:"username"` + PasswordHash string `json:"password_hash"` } -// GetUserIdByUsername retrieves the user ID associated with the given username. -// -// Parameters: -// - username: The username to query. -// -// Returns: -// - int: The user ID if found. -// - error: An error if the query fails or the username does not exist. -func GetUserIdByUsername(username string) (int, error) { - query := `SELECT id FROM Users WHERE username = ?` - var userID int - row := DB.QueryRow(query, username) - err := row.Scan(&userID) - if err != nil { // TODO: Is there a way this can return a 404 vs 500 error? this could be a 404 or 500, but we cannot tell from an err here - return -1, fmt.Errorf("Error fetching user ID for username '%v' (this usually means username does not exist) : %v", username, err) +// CreateUser inserts a new user into the database +func CreateUser(user *ApiUser) (int, error) { + linksJson, err := json.Marshal(user.Links) + if err != nil { + return 0, fmt.Errorf("failed to marshal links: %w", err) + } + settingsJson, err := json.Marshal(user.Settings) + if err != nil { + return 0, fmt.Errorf("failed to marshal settings: %w", err) + } + + // Use $1, $2, etc. for parameter placeholders in PostgreSQL + query := ` + INSERT INTO users (username, picture, bio, links, settings, creation_date) + VALUES ($1, $2, $3, $4, $5, NOW()) + RETURNING id; + ` + var newId int + err = DB.QueryRow( + query, + user.Username, + user.Picture, + user.Bio, + linksJson, + settingsJson, + ).Scan(&newId) + if err != nil { + return 0, fmt.Errorf("failed to insert user: %w", err) } - return userID, nil + return newId, nil } -// QueryUsername retrieves all user data for the given username. -// -// Parameters: -// - username: The username to query. -// -// Returns: -// - *types.User: The user details if found. -// - error: An error if the query or data parsing fails. -func QueryUsername(username string) (*types.User, error) { - query := `SELECT username, picture, bio, links, creation_date FROM Users WHERE username = ?;` - - row := DB.QueryRow(query, username) - - var user types.User - var linksJSON string - err := row.Scan(&user.Username, &user.Picture, &user.Bio, &linksJSON, &user.CreationDate) +// GetUserByUsername retrieves a user by their username +func GetUserByUsername(username string) (*ApiUser, error) { + query := ` + SELECT id, username, picture, bio, links, settings, creation_date + FROM users + WHERE LOWER(username) = LOWER($1) + ORDER BY CASE WHEN username = $1 THEN 0 ELSE 1 END, id ASC + LIMIT 1; + ` + user := &ApiUser{} + var links, settings []byte + err := DB.QueryRow(query, username).Scan( + &user.Id, + &user.Username, + &user.Picture, + &user.Bio, + &links, + &settings, + &user.CreationDate, + ) if err != nil { if err == sql.ErrNoRows { - return nil, nil + return nil, nil // User not found } - return nil, err + return nil, fmt.Errorf("failed to get user by username: %w", err) } - var links []string - err = json.Unmarshal([]byte(linksJSON), &links) - if err != nil { - return nil, err + if err := json.Unmarshal(links, &user.Links); err != nil { + log.Printf("WARN: could not unmarshal user links: %v", err) + } + if err := json.Unmarshal(settings, &user.Settings); err != nil { + log.Printf("WARN: could not unmarshal user settings: %v", err) } - user.Links = links - return &user, nil + return user, nil } -// QueryCreateUser creates a new user in the database. -// -// Parameters: -// - user: The user data to insert. -// -// Returns: -// - error: An error if the user creation fails. -func QueryCreateUser(user *types.User) error { - linksJSON, err := json.Marshal(user.Links) +// GetUserById retrieves a user by their ID +func GetUserById(id int) (*ApiUser, error) { + query := ` + SELECT id, username, picture, bio, links, settings, creation_date + FROM users + WHERE id = $1; + ` + user := &ApiUser{} + var links, settings []byte + err := DB.QueryRow(query, id).Scan( + &user.Id, + &user.Username, + &user.Picture, + &user.Bio, + &links, + &settings, + &user.CreationDate, + ) if err != nil { - return fmt.Errorf("Failed to marshal links for user '%v': %v", user.Username, err) + if err == sql.ErrNoRows { + return nil, nil // User not found + } + return nil, fmt.Errorf("failed to get user by id: %w", err) } - currentTime := time.Now().UTC() + if err := json.Unmarshal(links, &user.Links); err != nil { + log.Printf("WARN: could not unmarshal user links: %v", err) + } + if err := json.Unmarshal(settings, &user.Settings); err != nil { + log.Printf("WARN: could not unmarshal user settings: %v", err) + } - query := `INSERT INTO Users (username, picture, bio, links, creation_date) - VALUES (?, ?, ?, ?, ?);` + return user, nil +} - res, err := DB.Exec(query, user.Username, user.Picture, user.Bio, string(linksJSON), currentTime) +// UpdateUser updates a user's information +func UpdateUser(user *ApiUser) error { + linksJson, err := json.Marshal(user.Links) if err != nil { - return fmt.Errorf("Failed to create user '%v': %v", user.Username, err) + return fmt.Errorf("failed to marshal links: %w", err) } - - _, err = res.LastInsertId() + settingsJson, err := json.Marshal(user.Settings) if err != nil { - return fmt.Errorf("Failed to ensure user was created: %v", err) + return fmt.Errorf("failed to marshal settings: %w", err) } + query := ` + UPDATE users + SET picture = $1, bio = $2, links = $3, settings = $4 + WHERE username = $5; + ` + _, err = DB.Exec( + query, + user.Picture, + user.Bio, + linksJson, + settingsJson, + user.Username, + ) + if err != nil { + return fmt.Errorf("failed to update user: %w", err) + } return nil } -// QueryDeleteUser deletes a user by their username. -// -// Parameters: -// - username: The username of the user to delete. -// -// Returns: -// - int16: HTTP-like status code indicating the result. -// - error: An error if the deletion fails. -func QueryDeleteUser(username string) (int16, error) { - query := `DELETE from Users WHERE username=?;` - res, err := DB.Exec(query, username) +// DeleteUser deletes a user by their username +func DeleteUser(username string) error { + tx, err := DB.Begin() if err != nil { - return http.StatusBadRequest, fmt.Errorf("Failed to delete user '%v': %v", username, err) + return fmt.Errorf("failed to start user delete transaction: %w", err) } - RowsAffected, err := res.RowsAffected() - if RowsAffected == 0 { - return http.StatusNotFound, fmt.Errorf("Deletion did not affect any records") - } else if err != nil { - return http.StatusInternalServerError, fmt.Errorf("Failed to fetch affected rows: %v", err) + rollback := func(original error) error { + if rollbackErr := tx.Rollback(); rollbackErr != nil { + return fmt.Errorf("%v (rollback failed: %v)", original, rollbackErr) + } + return original } - return http.StatusOK, nil -} - -// QueryUpdateUser updates a user's details by their username. -// -// Parameters: -// - username: The username of the user to update. -// - updatedData: A map of fields to update and their new values. -// -// Returns: -// - error: An error if the update fails or no user is found. -func QueryUpdateUser(username string, updatedData map[string]interface{}) error { - - newUsername, usernameExists := updatedData["username"] - usernameStr, parseOk := newUsername.(string) + var userID int + if err := tx.QueryRow("SELECT id FROM users WHERE username = $1", username).Scan(&userID); err != nil { + if err == sql.ErrNoRows { + return rollback(fmt.Errorf("user not found")) + } + return rollback(fmt.Errorf("failed to resolve user id: %w", err)) + } + + _, err = tx.Exec( + `DELETE FROM comments WHERE id IN ( + SELECT DISTINCT comment_id FROM ( + SELECT pc.comment_id + FROM postcomments pc + JOIN posts p ON p.id = pc.post_id + WHERE p.user_id = $1 OR p.project_id IN (SELECT id FROM projects WHERE owner = $1) + UNION + SELECT prc.comment_id + FROM projectcomments prc + JOIN projects pr ON pr.id = prc.project_id + WHERE pr.owner = $1 + ) owned_comment_ids + );`, + userID, + ) + if err != nil { + return rollback(fmt.Errorf("failed to delete comments linked to user-owned content: %w", err)) + } - // if there is a new username provided, ensure it is not empty - if usernameExists && parseOk && usernameStr == "" { - return fmt.Errorf("Updated username cannot be empty!") + if _, err := tx.Exec("DELETE FROM userlogininfo WHERE username = $1", username); err != nil { + return rollback(fmt.Errorf("failed to delete user login info: %w", err)) } - query := `UPDATE Users SET ` - var args []interface{} + if _, err := tx.Exec("DELETE FROM directmessages WHERE sender_id = $1 OR recipient_id = $1", userID); err != nil { + return rollback(fmt.Errorf("failed to delete user direct messages: %w", err)) + } - queryParams, args, err := BuildUpdateQuery(updatedData) + res, err := tx.Exec("DELETE FROM users WHERE id = $1", userID) if err != nil { - return fmt.Errorf("Error building query: %v", err) + return rollback(fmt.Errorf("failed to delete user: %w", err)) } - query += queryParams + " WHERE username = ?" - args = append(args, username) - - rowsAffected, err := ExecUpdate(query, args...) + rowsAffected, err := res.RowsAffected() if err != nil { - return fmt.Errorf("Error checking rows affected: %v", err) + return rollback(fmt.Errorf("failed to fetch affected rows while deleting user: %w", err)) } if rowsAffected == 0 { - return fmt.Errorf("No user found with username '%s' to update", username) + return rollback(fmt.Errorf("user not found")) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit user delete transaction: %w", err) } return nil } -// QueryGetUsersFollowersUsernames retrieves the usernames of users who follow the specified user. -// -// Parameters: -// - username: The username of the user. -// -// Returns: -// - []string: A list of usernames of the followers. -// - int: HTTP-like status code. -// - error: An error if the query fails. -func QueryGetUsersFollowersUsernames(username string) ([]string, int, error) { - userID, err := GetUserIdByUsername(username) +// GetUsers retrieves a list of all users +func GetUsers() ([]*ApiUser, error) { + query := ` + SELECT id, username, picture, bio, links, settings, creation_date + FROM users; + ` + rows, err := DB.Query(query) if err != nil { - return nil, http.StatusInternalServerError, err + return nil, fmt.Errorf("failed to get users: %w", err) } + defer rows.Close() - query := ` - SELECT u.username - FROM Users u - JOIN UserFollows uf ON u.id = uf.follower_id - WHERE uf.follows_id = ?` + var users []*ApiUser + for rows.Next() { + user := &ApiUser{} + var links, settings []byte + if err := rows.Scan( + &user.Id, + &user.Username, + &user.Picture, + &user.Bio, + &links, + &settings, + &user.CreationDate, + ); err != nil { + return nil, fmt.Errorf("failed to scan user row: %w", err) + } - return getUsersFollowingOrFollowersUsernames(query, userID) + if err := json.Unmarshal(links, &user.Links); err != nil { + log.Printf("WARN: could not unmarshal user links: %v", err) + } + if err := json.Unmarshal(settings, &user.Settings); err != nil { + log.Printf("WARN: could not unmarshal user settings: %v", err) + } + users = append(users, user) + } + + return users, nil } -// function to retrieve the user ids of the users who follow the given user -// -// Parameters: -// - username (string): the user to retrieve -// -// Returns: -// - []int: a list of user ids of users who follow the specified user -// - int: HTTP status code indicating the result of the operation -// - error: any error encountered during the query -func QueryGetUsersFollowers(username string) ([]int, int, error) { - userID, err := GetUserIdByUsername(username) +// FollowUser creates a follow relationship between two users +func FollowUser(followerUsername, followedUsername string) error { + follower, err := GetUserByUsername(followerUsername) if err != nil { - return nil, http.StatusInternalServerError, err + return fmt.Errorf("failed to get follower: %w", err) + } + if follower == nil { + return fmt.Errorf("follower not found") } - query := ` - SELECT u.id - FROM Users u - JOIN UserFollows uf ON u.id = uf.follower_id - WHERE uf.follows_id = ?` + followed, err := GetUserByUsername(followedUsername) + if err != nil { + return fmt.Errorf("failed to get followed user: %w", err) + } + if followed == nil { + return fmt.Errorf("followed user not found") + } - return getUsersFollowingOrFollowers(query, userID) + query := "INSERT INTO userfollows (follower_id, followed_id) VALUES ($1, $2) ON CONFLICT DO NOTHING;" + _, err = DB.Exec(query, follower.Id, followed.Id) + if err != nil { + return fmt.Errorf("failed to follow user: %w", err) + } + return nil } -// function to retrieve the usernames of the users who follow the given user -// -// Parameters: -// - username (string): the user to retrieve -// -// Returns: -// - []string: a list of usernames of users who follow the specified user -// - int: HTTP status code indicating the result of the operation -// - error: any error encountered during the query -func QueryGetUsersFollowingUsernames(username string) ([]string, int, error) { - userID, err := GetUserIdByUsername(username) +// UnfollowUser removes a follow relationship between two users +func UnfollowUser(followerUsername, followedUsername string) error { + follower, err := GetUserByUsername(followerUsername) if err != nil { - return nil, http.StatusNotFound, err + return fmt.Errorf("failed to get follower: %w", err) + } + if follower == nil { + return fmt.Errorf("follower not found") } - query := ` - SELECT u.username - FROM Users u - JOIN UserFollows uf ON u.id = uf.follows_id - WHERE uf.follower_id = ?` + followed, err := GetUserByUsername(followedUsername) + if err != nil { + return fmt.Errorf("failed to get followed user: %w", err) + } + if followed == nil { + return fmt.Errorf("followed user not found") + } - return getUsersFollowingOrFollowersUsernames(query, userID) + query := "DELETE FROM userfollows WHERE follower_id = $1 AND followed_id = $2;" + _, err = DB.Exec(query, follower.Id, followed.Id) + if err != nil { + return fmt.Errorf("failed to unfollow user: %w", err) + } + return nil } -// function to retrieve the ids of the users who follow the given user -// -// Parameters: -// - username (string) - the user to retrieve -// -// Returns: -// - []int: a list of user IDs of users who follow the specified user -// - int: HTTP status code indicating the result of the operation -// - error: any error encountered during the query -func QueryGetUsersFollowing(username string) ([]int, int, error) { - userID, err := GetUserIdByUsername(username) +// GetUserFollowers retrieves a list of users who follow the given user +func GetUserFollowers(username string) ([]*ApiUser, error) { + user, err := GetUserByUsername(username) if err != nil { - return nil, http.StatusNotFound, err + return nil, fmt.Errorf("failed to get user: %w", err) + } + if user == nil { + return nil, fmt.Errorf("user not found") } query := ` - SELECT u.id - FROM Users u - JOIN UserFollows uf ON u.id = uf.follows_id - WHERE uf.follower_id = ?` - - return getUsersFollowingOrFollowers(query, userID) -} - -// helper function to retrieve the followers or followings of a user by their IDs -// -// Parameters: -// - query (string): the SQL query to execute -// - userID (int): the ID of the user to find follow data for -// -// Returns: -// - []int: a list of user IDs for the followers or followings -// - int: HTTP status code -// - error: any error encountered during the query -func getUsersFollowingOrFollowers(query string, userID int) ([]int, int, error) { - rows, err := DB.Query(query, userID) + SELECT u.id, u.username, u.picture, u.bio, u.links, u.settings, u.creation_date + FROM users u + JOIN userfollows f ON u.id = f.follower_id + WHERE f.followed_id = $1; + ` + rows, err := DB.Query(query, user.Id) if err != nil { - return nil, http.StatusNotFound, err + return nil, fmt.Errorf("failed to get followers: %w", err) } defer rows.Close() - var users []int + var followers []*ApiUser for rows.Next() { - var username int - if err := rows.Scan(&username); err != nil { - return nil, http.StatusInternalServerError, err + follower := &ApiUser{} + var links, settings []byte + if err := rows.Scan( + &follower.Id, + &follower.Username, + &follower.Picture, + &follower.Bio, + &links, + &settings, + &follower.CreationDate, + ); err != nil { + return nil, fmt.Errorf("failed to scan follower row: %w", err) } - users = append(users, username) - } - if err := rows.Err(); err != nil { - return nil, http.StatusInternalServerError, err + if err := json.Unmarshal(links, &follower.Links); err != nil { + log.Printf("WARN: could not unmarshal follower links: %v", err) + } + if err := json.Unmarshal(settings, &follower.Settings); err != nil { + log.Printf("WARN: could not unmarshal follower settings: %v", err) + } + followers = append(followers, follower) } - return users, http.StatusOK, nil + return followers, nil } -// helper function to retrieve the followers or followings of a user by their usernames -// -// Parameters: -// - query (string): the SQL query to execute -// - userID (int): the ID of the user to find follow data for -// -// Returns: -// - []string: a list of usernames for the followers or followings -// - int: HTTP status code -// - error: any error encountered during the query -func getUsersFollowingOrFollowersUsernames(query string, userID int) ([]string, int, error) { - rows, err := DB.Query(query, userID) +// GetUserFollowing retrieves a list of users the given user follows +func GetUserFollowing(username string) ([]*ApiUser, error) { + user, err := GetUserByUsername(username) if err != nil { - return nil, http.StatusNotFound, err + return nil, fmt.Errorf("failed to get user: %w", err) + } + if user == nil { + return nil, fmt.Errorf("user not found") + } + + query := ` + SELECT u.id, u.username, u.picture, u.bio, u.links, u.settings, u.creation_date + FROM users u + JOIN userfollows f ON u.id = f.followed_id + WHERE f.follower_id = $1; + ` + rows, err := DB.Query(query, user.Id) + if err != nil { + return nil, fmt.Errorf("failed to get following: %w", err) } defer rows.Close() - var users []string + var following []*ApiUser for rows.Next() { - var username string - if err := rows.Scan(&username); err != nil { - return nil, http.StatusInternalServerError, err + followed := &ApiUser{} + var links, settings []byte + if err := rows.Scan( + &followed.Id, + &followed.Username, + &followed.Picture, + &followed.Bio, + &links, + &settings, + &followed.CreationDate, + ); err != nil { + return nil, fmt.Errorf("failed to scan followed row: %w", err) } - users = append(users, username) - } - if err := rows.Err(); err != nil { - return nil, http.StatusInternalServerError, err + if err := json.Unmarshal(links, &followed.Links); err != nil { + log.Printf("WARN: could not unmarshal followed links: %v", err) + } + if err := json.Unmarshal(settings, &followed.Settings); err != nil { + log.Printf("WARN: could not unmarshal followed settings: %v", err) + } + following = append(following, followed) } - return users, http.StatusOK, nil + return following, nil } -// function to create a follow relationship between two users -// -// Parameters: -// - user (string): the username of the user initiating the follow -// - newFollow (string): the username of the user to be followed -// -// Returns: -// - int: HTTP status code -// - error: any error encountered during the query -func CreateNewUserFollow(user string, newFollow string) (int, error) { - userID, err := GetUserIdByUsername(user) - if err != nil { - return http.StatusNotFound, fmt.Errorf("Cannot find user with username '%v'", user) - } - - newFollowID, err := GetUserIdByUsername(newFollow) - if err != nil { - return http.StatusNotFound, fmt.Errorf("Cannot find user with username '%v'", newFollow) - } - - currFollowers, httpCode, err := QueryGetUsersFollowing(user) +// GetUserFollowersUsernames retrieves a list of usernames who follow the given user +func GetUserFollowersUsernames(username string) ([]string, error) { + user, err := GetUserByUsername(username) if err != nil { - return httpCode, fmt.Errorf("Cannot retrieve user's following list: %v", err) + return nil, fmt.Errorf("failed to get user: %w", err) } - - if slices.Contains(currFollowers, newFollowID) { - return http.StatusConflict, fmt.Errorf("User '%v' is already being followed", newFollow) + if user == nil { + return nil, fmt.Errorf("user not found") } - query := `INSERT INTO UserFollows (follower_id, follows_id) VALUES (?, ?)` - rowsAffected, err := ExecUpdate(query, userID, newFollowID) + query := ` + SELECT u.username + FROM users u + JOIN userfollows f ON u.id = f.follower_id + WHERE f.followed_id = $1; + ` + rows, err := DB.Query(query, user.Id) if err != nil { - return http.StatusInternalServerError, fmt.Errorf("An error occurred adding follower: %v", err) + return nil, fmt.Errorf("failed to get follower usernames: %w", err) } + defer rows.Close() - if rowsAffected == 0 { - return http.StatusInternalServerError, fmt.Errorf("Failed to add the follow relationship") + var usernames []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, fmt.Errorf("failed to scan follower username: %w", err) + } + usernames = append(usernames, name) } - return http.StatusOK, nil + return usernames, nil } -// function to remove a follow relationship between two users -// -// Parameters: -// - user (string): the username of the user initiating the unfollow -// - unfollow (string): the username of the user to be unfollowed -// -// Returns: -// - int: HTTP status code -// - error: any error encountered during the query -func RemoveUserFollow(user string, unfollow string) (int, error) { - userID, err := GetUserIdByUsername(user) +// GetUserFollowingUsernames retrieves a list of usernames the given user follows +func GetUserFollowingUsernames(username string) ([]string, error) { + user, err := GetUserByUsername(username) if err != nil { - return http.StatusNotFound, fmt.Errorf("Cannot find user with username '%v'", user) + return nil, fmt.Errorf("failed to get user: %w", err) } - - unfollowID, err := GetUserIdByUsername(unfollow) - if err != nil { - return http.StatusNotFound, fmt.Errorf("Cannot find user with username '%v'", unfollow) + if user == nil { + return nil, fmt.Errorf("user not found") } - currFollowers, httpCode, err := QueryGetUsersFollowing(user) + query := ` + SELECT u.username + FROM users u + JOIN userfollows f ON u.id = f.followed_id + WHERE f.follower_id = $1; + ` + rows, err := DB.Query(query, user.Id) if err != nil { - return httpCode, fmt.Errorf("Error retrieving user's following list: %v", err) + return nil, fmt.Errorf("failed to get following usernames: %w", err) } + defer rows.Close() - if !slices.Contains(currFollowers, unfollowID) { - return http.StatusConflict, fmt.Errorf("User '%v' is not being followed", unfollow) + var usernames []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, fmt.Errorf("failed to scan following username: %w", err) + } + usernames = append(usernames, name) } - query := `DELETE FROM UserFollows WHERE follower_id = ? AND follows_id = ?;` - rowsAffected, err := ExecUpdate(query, userID, unfollowID) + return usernames, nil +} + +// GetUserIdByUsername retrieves a user's ID by their username +func GetUserIdByUsername(username string) (int, error) { + var id int + query := ` + SELECT id + FROM users + WHERE LOWER(username) = LOWER($1) + ORDER BY CASE WHEN username = $1 THEN 0 ELSE 1 END, id ASC + LIMIT 1; + ` + err := DB.QueryRow(query, username).Scan(&id) if err != nil { - return http.StatusInternalServerError, fmt.Errorf("An error occurred removing follower: %v", err) + if err == sql.ErrNoRows { + return 0, fmt.Errorf("user not found") + } + return 0, fmt.Errorf("failed to get user id by username: %w", err) } + return id, nil +} - if rowsAffected == 0 { - return http.StatusConflict, fmt.Errorf("No such follow relationship exists") +// GetUserLoginInfo retrieves the login information for a user +func GetUserLoginInfo(username string) (*UserLoginInfo, error) { + query := ` + SELECT username, password_hash + FROM userlogininfo + WHERE LOWER(username) = LOWER($1) + ORDER BY CASE WHEN username = $1 THEN 0 ELSE 1 END + LIMIT 1; + ` + info := &UserLoginInfo{} + err := DB.QueryRow(query, username).Scan(&info.Username, &info.PasswordHash) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil // User not found + } + return nil, fmt.Errorf("failed to get user login info: %w", err) } + return info, nil +} - return http.StatusOK, nil +// CreateUserLoginInfo creates a new login info record for a user +func CreateUserLoginInfo(info *UserLoginInfo) error { + query := "INSERT INTO UserLoginInfo (username, password_hash) VALUES ($1, $2);" + _, err := DB.Exec(query, info.Username, info.PasswordHash) + if err != nil { + return fmt.Errorf("failed to create user login info: %w", err) + } + return nil } + diff --git a/backend/api/internal/database/utils.go b/backend/api/internal/database/utils.go index 8312e04..fbd8e6b 100644 --- a/backend/api/internal/database/utils.go +++ b/backend/api/internal/database/utils.go @@ -91,6 +91,7 @@ func ExecUpdate(query string, args ...interface{}) (int64, error) { func BuildUpdateQuery(updatedData map[string]interface{}) (string, []interface{}, error) { var query string var args []interface{} + placeholderIndex := 1 // dynamically add fields to the query based on the available data in updatedData for key, value := range updatedData { @@ -103,16 +104,18 @@ func BuildUpdateQuery(updatedData map[string]interface{}) (string, []interface{} // if needs changes. This allows for only awkward // datatypes, like the links, to be handled differently. switch key { - case "links", "tags": + case "links", "tags", "settings", "media": jsonData, err := MarshalToJSON(value) if err != nil { return "", nil, fmt.Errorf("Error marshaling list data for key `%v`: %v", key, err) } - query += fmt.Sprintf("%v = ?, ", key) + query += fmt.Sprintf("%v = $%d, ", key, placeholderIndex) args = append(args, string(jsonData)) + placeholderIndex++ default: - query += fmt.Sprintf("%v = ?, ", key) + query += fmt.Sprintf("%v = $%d, ", key, placeholderIndex) args = append(args, value) + placeholderIndex++ } } diff --git a/backend/api/internal/handlers/auth_middleware.go b/backend/api/internal/handlers/auth_middleware.go new file mode 100644 index 0000000..e510e94 --- /dev/null +++ b/backend/api/internal/handlers/auth_middleware.go @@ -0,0 +1,68 @@ +package handlers + +import ( + "net/http" + "strings" + + "backend/api/internal/auth" + + "github.com/gin-gonic/gin" +) + +const authUserIDKey = "authUserID" +const authUsernameKey = "authUsername" + +func RequireAuth() gin.HandlerFunc { + return func(context *gin.Context) { + authorization := context.GetHeader("Authorization") + if authorization == "" || !strings.HasPrefix(authorization, "Bearer ") { + RespondWithError(context, http.StatusUnauthorized, "Missing auth token") + context.Abort() + return + } + + token := strings.TrimSpace(strings.TrimPrefix(authorization, "Bearer ")) + claims, err := auth.ParseToken(token) + if err != nil { + RespondWithError(context, http.StatusUnauthorized, "Invalid auth token") + context.Abort() + return + } + + context.Set(authUserIDKey, claims.UserID) + context.Set(authUsernameKey, claims.Username) + context.Next() + } +} + +func RequireSameUser() gin.HandlerFunc { + return func(context *gin.Context) { + paramUsername := strings.TrimSpace(context.Param("username")) + if paramUsername == "" { + context.Next() + return + } + + authUsername, ok := context.Get(authUsernameKey) + if !ok || authUsername == nil { + RespondWithError(context, http.StatusUnauthorized, "Auth user missing") + context.Abort() + return + } + + authUsernameValue, ok := authUsername.(string) + if !ok { + RespondWithError(context, http.StatusUnauthorized, "Auth user missing") + context.Abort() + return + } + + if !strings.EqualFold(paramUsername, strings.TrimSpace(authUsernameValue)) { + RespondWithError(context, http.StatusForbidden, "Forbidden") + context.Abort() + return + } + + context.Next() + } +} diff --git a/backend/api/internal/handlers/auth_routes.go b/backend/api/internal/handlers/auth_routes.go new file mode 100644 index 0000000..c4fa7f9 --- /dev/null +++ b/backend/api/internal/handlers/auth_routes.go @@ -0,0 +1,165 @@ +package handlers + +import ( + "fmt" + "net/http" + "strings" + + "backend/api/internal/auth" + "backend/api/internal/database" + + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" +) + +type RegisterRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + Bio string `json:"bio"` + Links []string `json:"links"` + Picture string `json:"picture"` +} + +type LoginRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} + +type AuthResponse struct { + Token string `json:"token"` + User database.ApiUser `json:"user"` +} + +func Register(context *gin.Context) { + var request RegisterRequest + if err := context.BindJSON(&request); err != nil { + RespondWithError(context, http.StatusBadRequest, "Invalid register request") + return + } + + if len(request.Password) < 6 { + RespondWithError(context, http.StatusBadRequest, "Password must be at least 6 characters") + return + } + + existing, err := database.GetUserByUsername(request.Username) + if err != nil { + RespondWithError(context, http.StatusInternalServerError, "Failed to check user") + return + } + if existing != nil { + RespondWithError(context, http.StatusConflict, "Username already taken") + return + } + + passwordHash, err := bcrypt.GenerateFromPassword([]byte(request.Password), bcrypt.DefaultCost) + if err != nil { + RespondWithError(context, http.StatusInternalServerError, "Failed to secure password") + return + } + + newUser := &database.ApiUser{ + Username: request.Username, + Bio: request.Bio, + Links: map[string]interface{}{}, // Initialize empty map + Picture: request.Picture, + Settings: map[string]interface{}{}, // Initialize empty map + } + + if strings.TrimSpace(newUser.Picture) != "" { + storedPicture, err := materializeMediaReference(newUser.Picture) + if err != nil { + RespondWithError(context, http.StatusBadRequest, "Invalid picture media reference") + return + } + newUser.Picture = storedPicture + } + + // Convert links slice to map + if request.Links != nil { + linksMap := make(map[string]interface{}) + for i, link := range request.Links { + linksMap[fmt.Sprintf("link%d", i+1)] = link + } + newUser.Links = linksMap + } + + id, err := database.CreateUser(newUser) + if err != nil { + RespondWithError(context, http.StatusInternalServerError, fmt.Sprintf("Failed to create user: %v", err)) + return + } + newUser.Id = id + + loginInfo := &database.UserLoginInfo{ + Username: request.Username, + PasswordHash: string(passwordHash), + } + err = database.CreateUserLoginInfo(loginInfo) + if err != nil { + // Consider rolling back user creation + RespondWithError(context, http.StatusInternalServerError, fmt.Sprintf("Failed to create login info: %v", err)) + return + } + + token, err := auth.GenerateToken(int64(newUser.Id), newUser.Username) + if err != nil { + RespondWithError(context, http.StatusInternalServerError, "Failed to issue token") + return + } + + context.JSON(http.StatusCreated, AuthResponse{Token: token, User: *newUser}) +} + +func Login(context *gin.Context) { + var request LoginRequest + if err := context.BindJSON(&request); err != nil { + RespondWithError(context, http.StatusBadRequest, "Invalid login request") + return + } + + loginInfo, err := database.GetUserLoginInfo(request.Username) + if err != nil { + RespondWithError(context, http.StatusInternalServerError, fmt.Sprintf("Failed to login: %v", err)) + return + } + if loginInfo == nil { + RespondWithError(context, http.StatusUnauthorized, "Invalid credentials") + return + } + + if err := bcrypt.CompareHashAndPassword([]byte(loginInfo.PasswordHash), []byte(request.Password)); err != nil { + RespondWithError(context, http.StatusUnauthorized, "Invalid credentials") + return + } + + user, err := database.GetUserByUsername(request.Username) + if err != nil || user == nil { + RespondWithError(context, http.StatusUnauthorized, "Invalid credentials") + return + } + + token, err := auth.GenerateToken(int64(user.Id), user.Username) + if err != nil { + RespondWithError(context, http.StatusInternalServerError, "Failed to issue token") + return + } + + context.JSON(http.StatusOK, AuthResponse{Token: token, User: *user}) +} + +func GetMe(context *gin.Context) { + username := context.GetString(authUsernameKey) + if username == "" { + RespondWithError(context, http.StatusUnauthorized, "Unauthorized") + return + } + + user, err := database.GetUserByUsername(username) + if err != nil || user == nil { + RespondWithError(context, http.StatusUnauthorized, "Unauthorized") + return + } + + context.JSON(http.StatusOK, user) +} diff --git a/backend/api/internal/handlers/comment_routes.go b/backend/api/internal/handlers/comment_routes.go index 6721fa9..a561ce4 100644 --- a/backend/api/internal/handlers/comment_routes.go +++ b/backend/api/internal/handlers/comment_routes.go @@ -6,7 +6,6 @@ import ( "strconv" "backend/api/internal/database" - "backend/api/internal/types" "github.com/gin-gonic/gin" ) @@ -58,12 +57,6 @@ func GetCommentsByUserId(context *gin.Context) { RespondWithError(context, httpcode, fmt.Sprintf("Failed to fetch comments: %v", err)) return } - - if comments == nil { - RespondWithError(context, http.StatusNotFound, fmt.Sprintf("Comments from user with id %v not found", strId)) - return - } - context.JSON(http.StatusOK, comments) } @@ -86,12 +79,6 @@ func GetCommentsByProjectId(context *gin.Context) { RespondWithError(context, httpcode, fmt.Sprintf("Failed to fetch comments: %v", err)) return } - - if comments == nil { - RespondWithError(context, http.StatusNotFound, fmt.Sprintf("Comments from project with id %v not found", strId)) - return - } - context.JSON(http.StatusOK, comments) } @@ -114,12 +101,6 @@ func GetCommentsByPostId(context *gin.Context) { RespondWithError(context, httpcode, fmt.Sprintf("Failed to fetch comments: %v", err)) return } - - if comments == nil { - RespondWithError(context, http.StatusNotFound, fmt.Sprintf("Comments from post with id %v not found", strId)) - return - } - context.JSON(http.StatusOK, comments) } @@ -137,36 +118,44 @@ func GetCommentsByCommentId(context *gin.Context) { RespondWithError(context, http.StatusBadRequest, fmt.Sprintf("Failed to parse comment_id: %v", err)) return } - comments, httpcode, err := database.QueryCommentsByUserId(id) + comments, httpcode, err := database.QueryCommentsByCommentId(id) if err != nil { RespondWithError(context, httpcode, fmt.Sprintf("Failed to fetch comments: %v", err)) return } - - if comments == nil { - RespondWithError(context, http.StatusNotFound, fmt.Sprintf("Comments from comment with id %v not found", strId)) - return - } - context.JSON(http.StatusOK, comments) } // CreateCommentOnPost handles POST requests to create a new comment on a post -// It expects a JSON payload that can be bound to a `types.Comment` object. +// It expects a JSON payload that can be bound to a `database.Comment` object. // Validates the provided owner's ID, verifies the post, and ensures the user exists. // Returns: // - 400 Bad Request if the JSON payload is invalid or the user/post cannot be verified. // - 500 Internal Server Error if there is a database error. // On success, responds with a 201 Created status and the new comment ID in JSON format. func CreateCommentOnPost(context *gin.Context) { - var newComment types.Comment + var newComment database.Comment err := context.BindJSON(&newComment) + authUserID, ok := GetAuthUserID(context) + if ok && authUserID != newComment.User { + RespondWithError(context, http.StatusForbidden, "Comment user does not match auth user") + return + } if err != nil { RespondWithError(context, http.StatusBadRequest, fmt.Sprintf("Failed to bind to JSON: %v", err)) return } + if len(newComment.Media) > 0 { + normalizedMedia, mediaErr := materializeMediaList(newComment.Media) + if mediaErr != nil { + RespondWithError(context, http.StatusBadRequest, "Invalid media reference") + return + } + newComment.Media = normalizedMedia + } + strId := context.Param("post_id") postId, err := strconv.Atoi(strId) if err != nil { @@ -175,14 +164,14 @@ func CreateCommentOnPost(context *gin.Context) { } // Verify the owner - username, err := database.GetUsernameById(newComment.User) + user, err := database.GetUserById(int(newComment.User)) if err != nil { RespondWithError(context, http.StatusBadRequest, fmt.Sprintf("Failed to verify comment ownership: %v", err)) return } - if username == "" { - RespondWithError(context, http.StatusBadRequest, fmt.Sprintf("Failed to verify comment ownership. User could not be found")) + if user == nil { + RespondWithError(context, http.StatusBadRequest, "Failed to verify comment ownership. User could not be found") return } @@ -194,7 +183,7 @@ func CreateCommentOnPost(context *gin.Context) { } if post == nil { - RespondWithError(context, http.StatusBadRequest, fmt.Sprintf("Failed to verify post. Post could not be found")) + RespondWithError(context, http.StatusBadRequest, "Failed to verify post. Post could not be found") return } @@ -205,25 +194,54 @@ func CreateCommentOnPost(context *gin.Context) { return } + if post.User != newComment.User { + postID64 := int64(post.ID) + commentID64 := int64(id) + actor, _ := database.GetUserById(int(newComment.User)) + createAndPushNotification( + int64(post.User), + int64(newComment.User), + "comment_post", + &postID64, + nil, + &commentID64, + notificationBody(actor.Username, "commented on your byte"), + ) + } + context.JSON(http.StatusCreated, gin.H{"message": fmt.Sprintf("Comment created successfully with id %v", id)}) } // CreateCommentOnProject handles POST requests to create a new comment on a project -// It expects a JSON payload that can be bound to a `types.Comment` object. +// It expects a JSON payload that can be bound to a `database.Comment` object. // Validates the provided owner's ID, verifies the project, and ensures the user exists. // Returns: // - 400 Bad Request if the JSON payload is invalid or the user/project cannot be verified. // - 500 Internal Server Error if there is a database error. // On success, responds with a 201 Created status and the new comment ID in JSON format. func CreateCommentOnProject(context *gin.Context) { - var newComment types.Comment + var newComment database.Comment err := context.BindJSON(&newComment) + authUserID, ok := GetAuthUserID(context) + if ok && authUserID != newComment.User { + RespondWithError(context, http.StatusForbidden, "Comment user does not match auth user") + return + } if err != nil { RespondWithError(context, http.StatusBadRequest, fmt.Sprintf("Failed to bind to JSON: %v", err)) return } + if len(newComment.Media) > 0 { + normalizedMedia, mediaErr := materializeMediaList(newComment.Media) + if mediaErr != nil { + RespondWithError(context, http.StatusBadRequest, "Invalid media reference") + return + } + newComment.Media = normalizedMedia + } + strId := context.Param("project_id") projId, err := strconv.Atoi(strId) if err != nil { @@ -232,14 +250,14 @@ func CreateCommentOnProject(context *gin.Context) { } // Verify the owner - username, err := database.GetUsernameById(newComment.User) + user, err := database.GetUserById(int(newComment.User)) if err != nil { RespondWithError(context, http.StatusBadRequest, fmt.Sprintf("Failed to verify comment ownership: %v", err)) return } - if username == "" { - RespondWithError(context, http.StatusBadRequest, fmt.Sprintf("Failed to verify comment ownership. User could not be found")) + if user == nil { + RespondWithError(context, http.StatusBadRequest, "Failed to verify comment ownership. User could not be found") return } @@ -251,7 +269,7 @@ func CreateCommentOnProject(context *gin.Context) { } if project == nil { - RespondWithError(context, http.StatusBadRequest, fmt.Sprintf("Failed to verify project. Project could not be found")) + RespondWithError(context, http.StatusBadRequest, "Failed to verify project. Project could not be found") return } @@ -266,21 +284,35 @@ func CreateCommentOnProject(context *gin.Context) { } // CreateCommentOnComment handles POST requests to create a new reply (comment) to another comment -// It expects a JSON payload that can be bound to a `types.Comment` object. +// It expects a JSON payload that can be bound to a `database.Comment` object. // Validates the provided owner's ID, verifies the parent comment, and ensures the user exists. // Returns: // - 400 Bad Request if the JSON payload is invalid or the user/parent comment cannot be verified. // - 500 Internal Server Error if there is a database error. // On success, responds with a 201 Created status and the new reply (comment) ID in JSON format. func CreateCommentOnComment(context *gin.Context) { - var newComment types.Comment + var newComment database.Comment err := context.BindJSON(&newComment) + authUserID, ok := GetAuthUserID(context) + if ok && authUserID != newComment.User { + RespondWithError(context, http.StatusForbidden, "Comment user does not match auth user") + return + } if err != nil { RespondWithError(context, http.StatusBadRequest, fmt.Sprintf("Failed to bind to JSON: %v", err)) return } + if len(newComment.Media) > 0 { + normalizedMedia, mediaErr := materializeMediaList(newComment.Media) + if mediaErr != nil { + RespondWithError(context, http.StatusBadRequest, "Invalid media reference") + return + } + newComment.Media = normalizedMedia + } + strId := context.Param("comment_id") commId, err := strconv.Atoi(strId) if err != nil { @@ -289,14 +321,14 @@ func CreateCommentOnComment(context *gin.Context) { } // Verify the owner - username, err := database.GetUsernameById(newComment.User) + user, err := database.GetUserById(int(newComment.User)) if err != nil { RespondWithError(context, http.StatusBadRequest, fmt.Sprintf("Failed to verify comment ownership: %v", err)) return } - if username == "" { - RespondWithError(context, http.StatusBadRequest, fmt.Sprintf("Failed to verify comment ownership. User could not be found")) + if user == nil { + RespondWithError(context, http.StatusBadRequest, "Failed to verify comment ownership. User could not be found") return } @@ -308,7 +340,7 @@ func CreateCommentOnComment(context *gin.Context) { } if parentComment == nil { - RespondWithError(context, http.StatusBadRequest, fmt.Sprintf("Failed to verify parent comment. Comment could not be found")) + RespondWithError(context, http.StatusBadRequest, "Failed to verify parent comment. Comment could not be found") return } @@ -337,6 +369,22 @@ func DeleteComment(context *gin.Context) { return } + existingComment, err := database.QueryComment(id) + if err != nil { + RespondWithError(context, http.StatusInternalServerError, fmt.Sprintf("Failed to retrieve comment: %v", err)) + return + } + if existingComment == nil { + RespondWithError(context, http.StatusNotFound, fmt.Sprintf("Comment with id %v not found", id)) + return + } + + authUserID, ok := GetAuthUserID(context) + if ok && authUserID != existingComment.User { + RespondWithError(context, http.StatusForbidden, "Forbidden") + return + } + httpCode, err := database.QueryDeleteComment(id) if err != nil { RespondWithError(context, int(httpCode), fmt.Sprintf("Failed to delete comment: %v", err)) @@ -371,8 +419,15 @@ func UpdateCommentContent(context *gin.Context) { return } + authUserID, ok := GetAuthUserID(context) + if ok && authUserID != existingComment.User { + RespondWithError(context, http.StatusForbidden, "Forbidden") + return + } + var requestData struct { - Content string `json:"content"` + Content string `json:"content"` + Media []string `json:"media"` } if err := context.BindJSON(&requestData); err != nil { @@ -385,7 +440,19 @@ func UpdateCommentContent(context *gin.Context) { return } - httpcode, err := database.QueryUpdateCommentContent(id, requestData.Content) + updatedData := map[string]interface{}{ + "content": requestData.Content, + } + if requestData.Media != nil { + normalizedMedia, mediaErr := materializeMediaList(requestData.Media) + if mediaErr != nil { + RespondWithError(context, http.StatusBadRequest, "Invalid media reference") + return + } + updatedData["media"] = normalizedMedia + } + + httpcode, err := database.QueryUpdateComment(id, updatedData) if err != nil { RespondWithError(context, int(httpcode), fmt.Sprintf("Error updating comment: %v", err)) return @@ -405,6 +472,7 @@ func UpdateCommentContent(context *gin.Context) { "likes": updatedComment.Likes, "parent_comment": updatedComment.ParentComment, "content": updatedComment.Content, + "media": updatedComment.Media, }, }) } diff --git a/backend/api/internal/handlers/direct_message_routes.go b/backend/api/internal/handlers/direct_message_routes.go new file mode 100644 index 0000000..6473c66 --- /dev/null +++ b/backend/api/internal/handlers/direct_message_routes.go @@ -0,0 +1,339 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "backend/api/internal/auth" + "backend/api/internal/database" + "backend/api/internal/logger" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +type directMessageStreamEvent struct { + Type string `json:"type"` + DirectMessage database.DirectMessage `json:"direct_message"` +} + +type directMessageHub struct { + mu sync.RWMutex + subscribers map[string]map[chan directMessageStreamEvent]struct{} +} + +func newDirectMessageHub() *directMessageHub { + return &directMessageHub{ + subscribers: make(map[string]map[chan directMessageStreamEvent]struct{}), + } +} + +func (h *directMessageHub) subscribe(username string) chan directMessageStreamEvent { + channel := make(chan directMessageStreamEvent, 32) + normalized := normalizeUsername(username) + + h.mu.Lock() + defer h.mu.Unlock() + + if _, ok := h.subscribers[normalized]; !ok { + h.subscribers[normalized] = make(map[chan directMessageStreamEvent]struct{}) + } + h.subscribers[normalized][channel] = struct{}{} + return channel +} + +func (h *directMessageHub) unsubscribe(username string, channel chan directMessageStreamEvent) { + normalized := normalizeUsername(username) + + h.mu.Lock() + defer h.mu.Unlock() + + listeners, ok := h.subscribers[normalized] + if !ok { + return + } + + if _, exists := listeners[channel]; exists { + delete(listeners, channel) + close(channel) + } + + if len(listeners) == 0 { + delete(h.subscribers, normalized) + } +} + +func (h *directMessageHub) publish(message database.DirectMessage) { + event := directMessageStreamEvent{ + Type: "direct_message", + DirectMessage: message, + } + + targets := []string{message.SenderName, message.RecipientName} + for _, target := range targets { + h.publishToUser(target, event) + } +} + +func (h *directMessageHub) publishToUser(username string, event directMessageStreamEvent) { + normalized := normalizeUsername(username) + + h.mu.RLock() + listeners, ok := h.subscribers[normalized] + if !ok || len(listeners) == 0 { + h.mu.RUnlock() + return + } + channels := make([]chan directMessageStreamEvent, 0, len(listeners)) + for channel := range listeners { + channels = append(channels, channel) + } + h.mu.RUnlock() + + for _, channel := range channels { + select { + case channel <- event: + default: + } + } +} + +func normalizeUsername(value string) string { + return strings.ToLower(strings.TrimSpace(strings.TrimPrefix(value, "@"))) +} + +func extractToken(context *gin.Context) string { + authorization := strings.TrimSpace(context.GetHeader("Authorization")) + if strings.HasPrefix(strings.ToLower(authorization), "bearer ") { + return strings.TrimSpace(authorization[7:]) + } + return strings.TrimSpace(context.Query("token")) +} + +func parseTokenClaims(context *gin.Context) (*auth.Claims, bool) { + token := extractToken(context) + if token == "" { + RespondWithError(context, http.StatusUnauthorized, "Missing auth token") + return nil, false + } + + claims, err := auth.ParseToken(token) + if err != nil { + RespondWithError(context, http.StatusUnauthorized, "Invalid auth token") + return nil, false + } + + return claims, true +} + +var wsUpgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(_ *http.Request) bool { + return true + }, +} + +var dmHub = newDirectMessageHub() + +type DirectMessageCreateRequest struct { + Content string `json:"content"` +} + +func GetDirectMessages(context *gin.Context) { + username := context.Param("username") + other := context.Param("other") + + start := 0 + count := 100 + if raw := context.Query("start"); raw != "" { + if value, err := strconv.Atoi(raw); err == nil && value >= 0 { + start = value + } + } + if raw := context.Query("count"); raw != "" { + if value, err := strconv.Atoi(raw); err == nil && value > 0 { + if value > 200 { + value = 200 + } + count = value + } + } + + items, status, err := database.QueryDirectMessages(username, other, start, count) + if err != nil { + RespondWithError(context, status, fmt.Sprintf("Failed to fetch direct messages: %v", err)) + return + } + + context.JSON(http.StatusOK, items) +} + +func CreateDirectMessage(context *gin.Context) { + username := context.Param("username") + other := context.Param("other") + + var request DirectMessageCreateRequest + if err := context.BindJSON(&request); err != nil { + RespondWithError(context, http.StatusBadRequest, "Invalid request") + return + } + + content := strings.TrimSpace(request.Content) + if content == "" { + RespondWithError(context, http.StatusBadRequest, "Message content cannot be empty") + return + } + + message, status, err := database.QueryCreateDirectMessage(username, other, content) + if err != nil { + RespondWithError(context, status, fmt.Sprintf("Failed to create direct message: %v", err)) + return + } + + dmHub.publish(*message) + + createAndPushNotification( + message.RecipientID, + message.SenderID, + "direct_message", + nil, + nil, + nil, + notificationBody(message.SenderName, "sent you a message"), + ) + + context.JSON(http.StatusCreated, gin.H{"message": "Message sent", "direct_message": message}) +} + +func StreamDirectMessages(context *gin.Context) { + username := normalizeUsername(context.Param("username")) + if username == "" { + RespondWithError(context, http.StatusBadRequest, "Username required") + return + } + + claims, ok := parseTokenClaims(context) + if !ok { + return + } + + if normalizeUsername(claims.Username) != username { + RespondWithError(context, http.StatusForbidden, "Forbidden") + return + } + + if !websocket.IsWebSocketUpgrade(context.Request) { + logger.Log.WithFields(map[string]interface{}{ + "path": context.Request.URL.Path, + "upgrade": context.GetHeader("Upgrade"), + "connection": context.GetHeader("Connection"), + "user_agent": context.GetHeader("User-Agent"), + }).Warn("Direct message stream request missing websocket upgrade headers") + RespondWithError(context, http.StatusBadRequest, "Failed to establish stream") + return + } + + connection, err := wsUpgrader.Upgrade(context.Writer, context.Request, nil) + if err != nil { + logger.Log.WithFields(map[string]interface{}{ + "path": context.Request.URL.Path, + "upgrade": context.GetHeader("Upgrade"), + "connection": context.GetHeader("Connection"), + "sec_websocket_key": context.GetHeader("Sec-WebSocket-Key") != "", + "sec_websocket_version": context.GetHeader("Sec-WebSocket-Version"), + "user_agent": context.GetHeader("User-Agent"), + "error": err.Error(), + }).Warn("Direct message stream websocket upgrade failed") + RespondWithError(context, http.StatusBadRequest, "Failed to establish stream") + return + } + defer connection.Close() + + stream := dmHub.subscribe(username) + defer dmHub.unsubscribe(username, stream) + + _ = connection.SetReadDeadline(time.Now().Add(30 * time.Second)) + connection.SetPongHandler(func(_ string) error { + _ = connection.SetReadDeadline(time.Now().Add(30 * time.Second)) + return nil + }) + + go func() { + for { + if _, _, readErr := connection.ReadMessage(); readErr != nil { + _ = connection.Close() + return + } + } + }() + + pingTicker := time.NewTicker(20 * time.Second) + defer pingTicker.Stop() + + for { + select { + case event := <-stream: + payload, marshalErr := json.Marshal(event) + if marshalErr != nil { + continue + } + _ = connection.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if err := connection.WriteMessage(websocket.TextMessage, payload); err != nil { + return + } + case <-pingTicker.C: + _ = connection.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if err := connection.WriteMessage(websocket.PingMessage, []byte("ping")); err != nil { + return + } + case <-context.Request.Context().Done(): + return + } + } +} + +func GetDirectChatPeers(context *gin.Context) { + username := context.Param("username") + + peers, status, err := database.QueryDirectChatPeers(username) + if err != nil { + RespondWithError(context, status, fmt.Sprintf("Failed to fetch chat peers: %v", err)) + return + } + + context.JSON(http.StatusOK, gin.H{"message": "Successfully got chat peers", "peers": peers}) +} + +func GetDirectMessageThreads(context *gin.Context) { + username := context.Param("username") + + start := 0 + count := 50 + if raw := context.Query("start"); raw != "" { + if value, err := strconv.Atoi(raw); err == nil && value >= 0 { + start = value + } + } + if raw := context.Query("count"); raw != "" { + if value, err := strconv.Atoi(raw); err == nil && value > 0 { + if value > 200 { + value = 200 + } + count = value + } + } + + threads, status, err := database.QueryDirectMessageThreads(username, start, count) + if err != nil { + RespondWithError(context, status, fmt.Sprintf("Failed to fetch direct message threads: %v", err)) + return + } + + context.JSON(http.StatusOK, gin.H{"message": "Successfully got message threads", "threads": threads}) +} diff --git a/backend/api/internal/handlers/feed_routes.go b/backend/api/internal/handlers/feed_routes.go index 0da089a..13112fc 100644 --- a/backend/api/internal/handlers/feed_routes.go +++ b/backend/api/internal/handlers/feed_routes.go @@ -4,12 +4,25 @@ import ( "fmt" "net/http" "strconv" + "strings" "backend/api/internal/database" - "backend/api/internal/types" "github.com/gin-gonic/gin" ) +func normalizeFeedSort(sort string) string { + switch strings.ToLower(strings.TrimSpace(sort)) { + case "likes", "popular": + return "popular" + case "hot": + return "hot" + case "new", "recent", "time", "": + return "recent" + default: + return "" + } +} + // GetPostsFeed handles GET requests to retrieve a set of posts for the feed // It expects the URL parameters of `type`, `start`, and `count` // Returns: @@ -19,38 +32,54 @@ import ( // On success, responds with a 200 OK status and the post feed in JSON format. func GetPostsFeed(context *gin.Context) { feedType := context.Query("type") + feedSort := context.Query("sort") strStart := context.Query("start") strCount := context.Query("count") - if feedType == "" || strStart == "" || strCount == "" { + const maxCount = 50 + + if strStart == "" || strCount == "" { RespondWithError(context, http.StatusBadRequest, "Missing one or more required url query parameters: type, start, or count") return } + if feedSort == "" { + feedSort = feedType + } + feedSort = normalizeFeedSort(feedSort) + if feedSort == "" { + RespondWithError(context, http.StatusBadRequest, fmt.Sprintf("Invalid feed sort passed: %v", feedType)) + return + } + start, err := strconv.Atoi(strStart) if err != nil { RespondWithError(context, http.StatusBadRequest, fmt.Sprintf("Failed to parse starting int: %v", err)) return } + if start < 0 { + RespondWithError(context, http.StatusBadRequest, "Start must be 0 or greater") + return + } count, err := strconv.Atoi(strCount) if err != nil { RespondWithError(context, http.StatusBadRequest, fmt.Sprintf("Failed to parse count int: %v", err)) return } - var posts []types.Post = []types.Post{} - var code int - switch feedType { - case "time": - posts, code, err = database.GetPostByTimeFeed(start, count) - case "likes": - posts, code, err = database.GetPostByLikesFeed(start, count) - default: - RespondWithError(context, http.StatusBadRequest, fmt.Sprintf("Invalid feed type passed: %v", feedType)) + if count <= 0 { + RespondWithError(context, http.StatusBadRequest, "Count must be greater than 0") return } + if count > maxCount { + count = maxCount + } + var posts []database.Post = []database.Post{} + var code int + posts, code, err = database.GetPostFeedBySort(start, count, feedSort) if err != nil { RespondWithError(context, code, fmt.Sprintf("An error occurred getting feed: %v", err)) + return } context.JSON(http.StatusOK, posts) } @@ -64,38 +93,174 @@ func GetPostsFeed(context *gin.Context) { // On success, responds with a 200 OK status and the post feed in JSON format. func GetProjectsFeed(context *gin.Context) { feedType := context.Query("type") + feedSort := context.Query("sort") strStart := context.Query("start") strCount := context.Query("count") - if feedType == "" || strStart == "" || strCount == "" { + const maxCount = 50 + + if strStart == "" || strCount == "" { RespondWithError(context, http.StatusBadRequest, "Missing one or more required url query parameters: type, start, or count") return } + if feedSort == "" { + feedSort = feedType + } + feedSort = normalizeFeedSort(feedSort) + if feedSort == "" { + RespondWithError(context, http.StatusBadRequest, fmt.Sprintf("Invalid feed sort passed: %v", feedType)) + return + } + start, err := strconv.Atoi(strStart) if err != nil { RespondWithError(context, http.StatusBadRequest, fmt.Sprintf("Failed to parse starting int: %v", err)) return } + if start < 0 { + RespondWithError(context, http.StatusBadRequest, "Start must be 0 or greater") + return + } count, err := strconv.Atoi(strCount) if err != nil { RespondWithError(context, http.StatusBadRequest, fmt.Sprintf("Failed to parse count int: %v", err)) return } - var projects []types.Project = []types.Project{} - var code int - switch feedType { - case "time": - projects, code, err = database.GetProjectByTimeFeed(start, count) - case "likes": - projects, code, err = database.GetProjectByLikesFeed(start, count) - default: - RespondWithError(context, http.StatusBadRequest, fmt.Sprintf("Invalid feed type passed: %v", feedType)) + if count <= 0 { + RespondWithError(context, http.StatusBadRequest, "Count must be greater than 0") return } + if count > maxCount { + count = maxCount + } + var projects []database.Project = []database.Project{} + var code int + projects, code, err = database.GetProjectFeedBySort(start, count, feedSort) if err != nil { RespondWithError(context, code, fmt.Sprintf("An error occurred getting feed: %v", err)) + return + } + context.JSON(http.StatusOK, projects) +} + +func parseFeedPagination(context *gin.Context) (int, int, bool) { + strStart := context.Query("start") + strCount := context.Query("count") + const maxCount = 50 + + if strStart == "" || strCount == "" { + RespondWithError(context, http.StatusBadRequest, "Missing one or more required url query parameters: start or count") + return 0, 0, false + } + + start, err := strconv.Atoi(strStart) + if err != nil { + RespondWithError(context, http.StatusBadRequest, fmt.Sprintf("Failed to parse starting int: %v", err)) + return 0, 0, false + } + if start < 0 { + RespondWithError(context, http.StatusBadRequest, "Start must be 0 or greater") + return 0, 0, false + } + + count, err := strconv.Atoi(strCount) + if err != nil { + RespondWithError(context, http.StatusBadRequest, fmt.Sprintf("Failed to parse count int: %v", err)) + return 0, 0, false + } + if count <= 0 { + RespondWithError(context, http.StatusBadRequest, "Count must be greater than 0") + return 0, 0, false + } + if count > maxCount { + count = maxCount } + + return start, count, true +} + +func GetFollowingPostsFeed(context *gin.Context) { + username := context.Param("username") + sort := normalizeFeedSort(context.DefaultQuery("sort", "recent")) + if sort == "" { + RespondWithError(context, http.StatusBadRequest, "Invalid sort; expected one of: recent, new, popular, likes, hot") + return + } + start, count, ok := parseFeedPagination(context) + if !ok { + return + } + + posts, code, err := database.GetPostByFollowingFeed(username, start, count, sort) + if err != nil { + RespondWithError(context, code, fmt.Sprintf("An error occurred getting following posts feed: %v", err)) + return + } + + context.JSON(http.StatusOK, posts) +} + +func GetSavedPostsFeed(context *gin.Context) { + username := context.Param("username") + sort := normalizeFeedSort(context.DefaultQuery("sort", "recent")) + if sort == "" { + RespondWithError(context, http.StatusBadRequest, "Invalid sort; expected one of: recent, new, popular, likes, hot") + return + } + start, count, ok := parseFeedPagination(context) + if !ok { + return + } + + posts, code, err := database.GetPostBySavedFeed(username, start, count, sort) + if err != nil { + RespondWithError(context, code, fmt.Sprintf("An error occurred getting saved posts feed: %v", err)) + return + } + + context.JSON(http.StatusOK, posts) +} + +func GetFollowingProjectsFeed(context *gin.Context) { + username := context.Param("username") + sort := normalizeFeedSort(context.DefaultQuery("sort", "recent")) + if sort == "" { + RespondWithError(context, http.StatusBadRequest, "Invalid sort; expected one of: recent, new, popular, likes, hot") + return + } + start, count, ok := parseFeedPagination(context) + if !ok { + return + } + + projects, code, err := database.GetProjectByFollowingFeed(username, start, count, sort) + if err != nil { + RespondWithError(context, code, fmt.Sprintf("An error occurred getting following projects feed: %v", err)) + return + } + + context.JSON(http.StatusOK, projects) +} + +func GetSavedProjectsFeed(context *gin.Context) { + username := context.Param("username") + sort := normalizeFeedSort(context.DefaultQuery("sort", "recent")) + if sort == "" { + RespondWithError(context, http.StatusBadRequest, "Invalid sort; expected one of: recent, new, popular, likes, hot") + return + } + start, count, ok := parseFeedPagination(context) + if !ok { + return + } + + projects, code, err := database.GetProjectBySavedFeed(username, start, count, sort) + if err != nil { + RespondWithError(context, code, fmt.Sprintf("An error occurred getting saved projects feed: %v", err)) + return + } + context.JSON(http.StatusOK, projects) } diff --git a/backend/api/internal/handlers/media_ingest.go b/backend/api/internal/handlers/media_ingest.go new file mode 100644 index 0000000..26d25eb --- /dev/null +++ b/backend/api/internal/handlers/media_ingest.go @@ -0,0 +1,220 @@ +package handlers + +import ( + "bytes" + "encoding/base64" + "fmt" + "io" + "mime" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" +) + +const maxIngestedMediaBytes int64 = 64 * 1024 * 1024 + +var mediaHTTPClient = &http.Client{Timeout: 15 * time.Second} + +func coerceStringSlice(value interface{}) ([]string, bool) { + switch typed := value.(type) { + case []string: + result := make([]string, len(typed)) + copy(result, typed) + return result, true + case []interface{}: + result := make([]string, 0, len(typed)) + for _, item := range typed { + text, ok := item.(string) + if !ok { + return nil, false + } + result = append(result, text) + } + return result, true + default: + return nil, false + } +} + +func materializeMediaList(values []string) ([]string, error) { + if len(values) == 0 { + return values, nil + } + + normalized := make([]string, 0, len(values)) + for _, value := range values { + stored, err := materializeMediaReference(value) + if err != nil { + return nil, err + } + normalized = append(normalized, stored) + } + + return normalized, nil +} + +func materializeMediaReference(raw string) (string, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", nil + } + + // Check if this is already a managed upload path (e.g. "/uploads/abc123.jpg" + // or "uploads/abc123.jpg"). + if filename, managed := extractManagedUploadFilename(trimmed); managed { + filePath := filepath.Join(uploadDir, filename) + if _, statErr := os.Stat(filePath); statErr != nil { + if os.IsNotExist(statErr) { + return "", fmt.Errorf("managed media file not found") + } + return "", fmt.Errorf("failed to access managed media file") + } + return fmt.Sprintf("/%s/%s", uploadDir, filename), nil + } + + if strings.HasPrefix(trimmed, "data:") { + return materializeDataURI(trimmed) + } + + parsed, err := url.Parse(trimmed) + if err != nil { + return "", fmt.Errorf("invalid media reference") + } + + if parsed.Scheme == "http" || parsed.Scheme == "https" { + // Before downloading, check if the URL points to our own managed + // uploads directory (e.g. "https://devbits.ddns.net/uploads/abc.jpg"). + // If so, treat it as a local file to avoid a self-referential HTTP + // request that can hang or loop. + if filename, managed := extractManagedUploadFilename(parsed.Path); managed { + filePath := filepath.Join(uploadDir, filename) + if _, statErr := os.Stat(filePath); statErr == nil { + return fmt.Sprintf("/%s/%s", uploadDir, filename), nil + } + // File doesn't exist locally — fall through to remote download + // in case this is a legitimate external URL that happens to have + // an /uploads/ path. + } + return materializeRemoteURL(parsed) + } + + return "", fmt.Errorf("unsupported media reference scheme") +} + +func materializeDataURI(raw string) (string, error) { + commaIndex := strings.Index(raw, ",") + if commaIndex <= 0 { + return "", fmt.Errorf("invalid data uri") + } + + meta := raw[:commaIndex] + payload := raw[commaIndex+1:] + isBase64 := strings.Contains(strings.ToLower(meta), ";base64") + + mediaType := "application/octet-stream" + if strings.HasPrefix(strings.ToLower(meta), "data:") { + metaWithoutPrefix := strings.TrimPrefix(meta, "data:") + metaWithoutPrefix = strings.TrimSuffix(metaWithoutPrefix, ";base64") + if metaWithoutPrefix != "" { + mediaType = strings.Split(metaWithoutPrefix, ";")[0] + } + } + + var body []byte + var err error + if isBase64 { + body, err = base64.StdEncoding.DecodeString(payload) + } else { + decoded, decodeErr := url.QueryUnescape(payload) + if decodeErr != nil { + return "", fmt.Errorf("invalid data uri encoding") + } + body = []byte(decoded) + } + if err != nil { + return "", fmt.Errorf("invalid data uri payload") + } + + if int64(len(body)) > maxIngestedMediaBytes { + return "", fmt.Errorf("media file exceeds %d bytes", maxIngestedMediaBytes) + } + + ext := extensionFromContentType(mediaType) + return saveManagedUpload(body, ext) +} + +func materializeRemoteURL(parsed *url.URL) (string, error) { + request, err := http.NewRequest(http.MethodGet, parsed.String(), nil) + if err != nil { + return "", fmt.Errorf("invalid media url") + } + + response, err := mediaHTTPClient.Do(request) + if err != nil { + return "", fmt.Errorf("failed to download media") + } + defer response.Body.Close() + + if response.StatusCode < 200 || response.StatusCode >= 300 { + return "", fmt.Errorf("failed to download media") + } + + reader := io.LimitReader(response.Body, maxIngestedMediaBytes+1) + body, err := io.ReadAll(reader) + if err != nil { + return "", fmt.Errorf("failed to read downloaded media") + } + if int64(len(body)) > maxIngestedMediaBytes { + return "", fmt.Errorf("media file exceeds %d bytes", maxIngestedMediaBytes) + } + + contentType := response.Header.Get("Content-Type") + ext := extensionFromPathOrType(parsed.Path, contentType) + return saveManagedUpload(body, ext) +} + +func extensionFromPathOrType(pathValue, contentType string) string { + ext := strings.ToLower(filepath.Ext(pathValue)) + if ext != "" { + return ext + } + return extensionFromContentType(contentType) +} + +func extensionFromContentType(contentType string) string { + if contentType == "" { + return "" + } + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil { + mediaType = contentType + } + extensions, err := mime.ExtensionsByType(mediaType) + if err != nil || len(extensions) == 0 { + return "" + } + return strings.ToLower(extensions[0]) +} + +func saveManagedUpload(body []byte, ext string) (string, error) { + if err := os.MkdirAll(uploadDir, 0o755); err != nil { + return "", fmt.Errorf("failed to prepare upload directory") + } + + name, err := randomHex(12) + if err != nil { + return "", fmt.Errorf("failed to generate filename") + } + + filename := fmt.Sprintf("%s%s", name, ext) + path := filepath.Join(uploadDir, filename) + + if err := os.WriteFile(path, bytes.Clone(body), 0o644); err != nil { + return "", fmt.Errorf("failed to store media") + } + + return fmt.Sprintf("/%s/%s", uploadDir, filename), nil +} diff --git a/backend/api/internal/handlers/media_routes.go b/backend/api/internal/handlers/media_routes.go new file mode 100644 index 0000000..5d3dea3 --- /dev/null +++ b/backend/api/internal/handlers/media_routes.go @@ -0,0 +1,161 @@ +package handlers + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "mime" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + + "backend/api/internal/logger" + + "github.com/gin-gonic/gin" +) + +const uploadDir = "uploads" + +func UploadMedia(context *gin.Context) { + ct := context.Request.Header.Get("Content-Type") + if ct == "" || !strings.Contains(strings.ToLower(ct), "multipart/form-data") { + logger.Log.WithFields(map[string]interface{}{ + "content_type": ct, + "content_length": context.Request.ContentLength, + }).Warn("UploadMedia rejected – expected multipart/form-data") + context.JSON(http.StatusBadRequest, gin.H{ + "error": "Bad Request", + "message": "Content-Type must be multipart/form-data", + }) + return + } + + file, err := context.FormFile("file") + if err != nil { + file, err = context.FormFile("picture") + if err != nil { + file, err = context.FormFile("image") + if err != nil { + logMissingUpload(context, err) + context.JSON(http.StatusBadRequest, gin.H{ + "error": "Bad Request", + "message": fmt.Sprintf("Missing file: %v", err), + }) + return + } + } + } + + if err := os.MkdirAll(uploadDir, 0o755); err != nil { + RespondWithError(context, http.StatusInternalServerError, "Failed to prepare upload directory") + return + } + + ext := strings.ToLower(filepath.Ext(file.Filename)) + if ext == "" { + if file.Header.Get("Content-Type") != "" { + if guessed, err := mime.ExtensionsByType(file.Header.Get("Content-Type")); err == nil && len(guessed) > 0 { + ext = guessed[0] + } + } + } + + name, err := randomHex(12) + if err != nil { + RespondWithError(context, http.StatusInternalServerError, "Failed to generate filename") + return + } + + filename := buildManagedUploadFilename(context, name, ext) + path := filepath.Join(uploadDir, filename) + if err := context.SaveUploadedFile(file, path); err != nil { + RespondWithError(context, http.StatusInternalServerError, "Failed to save file") + return + } + + scheme := "http" + if context.Request.TLS != nil { + scheme = "https" + } + relativeURL := fmt.Sprintf("/%s/%s", uploadDir, filename) + absoluteURL := fmt.Sprintf("%s://%s%s", scheme, context.Request.Host, relativeURL) + + context.JSON(http.StatusOK, gin.H{ + "url": relativeURL, + "absolute_url": absoluteURL, + "filename": filename, + "contentType": file.Header.Get("Content-Type"), + "size": file.Size, + }) +} + +func logMissingUpload(context *gin.Context, err error) { + contentType := context.Request.Header.Get("Content-Type") + contentLength := context.Request.ContentLength + formErr := context.Request.ParseMultipartForm(64 << 20) + formFieldKeys := []string{} + formFileKeys := []string{} + if context.Request.MultipartForm != nil { + for key := range context.Request.MultipartForm.Value { + formFieldKeys = append(formFieldKeys, key) + } + for key := range context.Request.MultipartForm.File { + formFileKeys = append(formFileKeys, key) + } + } + + logger.Log.WithFields(map[string]interface{}{ + "content_type": contentType, + "content_length": contentLength, + "parse_error": fmt.Sprintf("%v", formErr), + "form_fields": formFieldKeys, + "form_files": formFileKeys, + "file_error": fmt.Sprintf("%v", err), + }).Warn("UploadMedia missing file payload") +} + +func randomHex(length int) (string, error) { + buf := make([]byte, length) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} + +func buildManagedUploadFilename(context *gin.Context, randomName, ext string) string { + if userID, ok := getAuthUserIDFromContext(context); ok { + return fmt.Sprintf("u%d_%s%s", userID, randomName, ext) + } + return fmt.Sprintf("%s%s", randomName, ext) +} + +func getAuthUserIDFromContext(context *gin.Context) (int, bool) { + raw, ok := context.Get(authUserIDKey) + if !ok || raw == nil { + return 0, false + } + + switch value := raw.(type) { + case int: + if value > 0 { + return value, true + } + case int64: + if value > 0 { + return int(value), true + } + case float64: + if value > 0 { + return int(value), true + } + case string: + parsed, err := strconv.Atoi(strings.TrimSpace(value)) + if err == nil && parsed > 0 { + return parsed, true + } + } + + return 0, false +} diff --git a/backend/api/internal/handlers/notifications_routes.go b/backend/api/internal/handlers/notifications_routes.go new file mode 100644 index 0000000..02f58c7 --- /dev/null +++ b/backend/api/internal/handlers/notifications_routes.go @@ -0,0 +1,291 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "backend/api/internal/database" + "backend/api/internal/logger" + + "github.com/gin-gonic/gin" +) + +type RegisterPushTokenRequest struct { + Token string `json:"token"` + Platform string `json:"platform"` +} + +type ExpoPushMessage struct { + To string `json:"to"` + Title string `json:"title"` + Body string `json:"body"` + Data map[string]interface{} `json:"data,omitempty"` +} + +type ExpoPushTicketResponse struct { + Data struct { + Status string `json:"status"` + Message string `json:"message"` + Details struct { + Error string `json:"error"` + } `json:"details"` + } `json:"data"` +} + +const expoPushURL = "https://exp.host/--/api/v2/push/send" + +var expoPushHTTPClient = &http.Client{Timeout: 4 * time.Second} + +func RegisterPushToken(context *gin.Context) { + userID, ok := GetAuthUserID(context) + if !ok { + RespondWithError(context, http.StatusUnauthorized, "Unauthorized") + return + } + + var request RegisterPushTokenRequest + if err := context.BindJSON(&request); err != nil { + RespondWithError(context, http.StatusBadRequest, "Invalid request") + return + } + + request.Token = strings.TrimSpace(request.Token) + request.Platform = strings.ToLower(strings.TrimSpace(request.Platform)) + if request.Token == "" || request.Platform == "" { + RespondWithError(context, http.StatusBadRequest, "Missing token or platform") + return + } + if !isValidExpoPushToken(request.Token) { + RespondWithError(context, http.StatusBadRequest, "Invalid Expo push token") + return + } + + status, err := database.UpsertPushToken(userID, request.Token, request.Platform) + if err != nil { + RespondWithError(context, status, fmt.Sprintf("Failed to store token: %v", err)) + return + } + + context.JSON(http.StatusOK, gin.H{"message": "Token registered"}) +} + +func GetNotifications(context *gin.Context) { + userID, ok := GetAuthUserID(context) + if !ok { + RespondWithError(context, http.StatusUnauthorized, "Unauthorized") + return + } + + start, count := 0, 50 + if raw := context.Query("start"); raw != "" { + if value, err := strconv.Atoi(raw); err == nil && value >= 0 { + start = value + } + } + if raw := context.Query("count"); raw != "" { + if value, err := strconv.Atoi(raw); err == nil && value > 0 { + count = value + } + } + + items, status, err := database.QueryNotificationsByUser(userID, start, count) + if err != nil { + RespondWithError(context, status, fmt.Sprintf("Failed to fetch notifications: %v", err)) + return + } + + context.JSON(http.StatusOK, items) +} + +func GetNotificationCount(context *gin.Context) { + userID, ok := GetAuthUserID(context) + if !ok { + RespondWithError(context, http.StatusUnauthorized, "Unauthorized") + return + } + + count, status, err := database.GetUnreadNotificationCount(userID) + if err != nil { + RespondWithError(context, status, fmt.Sprintf("Failed to fetch count: %v", err)) + return + } + + context.JSON(http.StatusOK, gin.H{"count": count}) +} + +func MarkNotificationRead(context *gin.Context) { + userID, ok := GetAuthUserID(context) + if !ok { + RespondWithError(context, http.StatusUnauthorized, "Unauthorized") + return + } + + id, err := strconv.Atoi(context.Param("notification_id")) + if err != nil { + RespondWithError(context, http.StatusBadRequest, "Invalid notification id") + return + } + + status, err := database.MarkNotificationRead(userID, int64(id)) + if err != nil { + RespondWithError(context, status, fmt.Sprintf("Failed to mark read: %v", err)) + return + } + + context.JSON(http.StatusOK, gin.H{"message": "Notification marked read"}) +} + +func DeleteNotification(context *gin.Context) { + userID, ok := GetAuthUserID(context) + if !ok { + RespondWithError(context, http.StatusUnauthorized, "Unauthorized") + return + } + + id, err := strconv.Atoi(context.Param("notification_id")) + if err != nil { + RespondWithError(context, http.StatusBadRequest, "Invalid notification id") + return + } + + status, err := database.DeleteNotification(userID, int64(id)) + if err != nil { + RespondWithError(context, status, fmt.Sprintf("Failed to delete: %v", err)) + return + } + + context.JSON(http.StatusOK, gin.H{"message": "Notification deleted"}) +} + +func ClearNotifications(context *gin.Context) { + userID, ok := GetAuthUserID(context) + if !ok { + RespondWithError(context, http.StatusUnauthorized, "Unauthorized") + return + } + + status, err := database.ClearNotifications(userID) + if err != nil { + RespondWithError(context, status, fmt.Sprintf("Failed to clear: %v", err)) + return + } + + context.JSON(http.StatusOK, gin.H{"message": "Notifications cleared"}) +} + +func SendNotificationPush(targetID int64, notification *database.Notification, body string) { + if notification == nil { + return + } + + tokens, status, err := database.QueryPushTokens(targetID) + if err != nil || status != http.StatusOK { + return + } + + payload := ExpoPushMessage{ + Title: "DevBits", + Body: body, + Data: map[string]interface{}{ + "actor_id": notification.ActorID, + "actor_name": notification.ActorName, + "type": notification.Type, + "post_id": notification.PostID, + "project_id": notification.ProjectID, + "comment_id": notification.CommentID, + }, + } + + const maxWorkers = 6 + workerCount := len(tokens) + if workerCount > maxWorkers { + workerCount = maxWorkers + } + if workerCount < 1 { + return + } + + tokensCh := make(chan database.PushToken) + for i := 0; i < workerCount; i++ { + go func() { + for token := range tokensCh { + sendPushToToken(token.Token, payload) + } + }() + } + + for _, token := range tokens { + tokensCh <- token + } + close(tokensCh) +} + +func isValidExpoPushToken(token string) bool { + return strings.HasPrefix(token, "ExponentPushToken[") || strings.HasPrefix(token, "ExpoPushToken[") +} + +func sendPushToToken(token string, basePayload ExpoPushMessage) { + if token == "" { + return + } + + payload := basePayload + payload.To = token + + bytesBody, err := json.Marshal(payload) + if err != nil { + return + } + + request, err := http.NewRequest("POST", expoPushURL, bytes.NewBuffer(bytesBody)) + if err != nil { + return + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Accept", "application/json") + + response, err := expoPushHTTPClient.Do(request) + if err != nil { + logger.Log.Warnf("push delivery failed for token: %v", err) + return + } + defer response.Body.Close() + + responseBytes, err := io.ReadAll(response.Body) + if err != nil { + return + } + + if response.StatusCode >= http.StatusBadRequest { + logger.Log.Warnf("expo push rejected token with status %d", response.StatusCode) + } + + if shouldDeleteToken(responseBytes) { + _, _ = database.DeletePushToken(token) + } +} + +func shouldDeleteToken(responseBytes []byte) bool { + if len(responseBytes) == 0 { + return false + } + + var ticket ExpoPushTicketResponse + if err := json.Unmarshal(responseBytes, &ticket); err != nil { + return false + } + + if ticket.Data.Status != "error" { + return false + } + + errorCode := strings.ToLower(strings.TrimSpace(ticket.Data.Details.Error)) + message := strings.ToLower(strings.TrimSpace(ticket.Data.Message)) + return errorCode == "devicenotregistered" || strings.Contains(message, "not a registered push notification recipient") +} diff --git a/backend/api/internal/handlers/notifications_routes_test.go b/backend/api/internal/handlers/notifications_routes_test.go new file mode 100644 index 0000000..b04d8c0 --- /dev/null +++ b/backend/api/internal/handlers/notifications_routes_test.go @@ -0,0 +1,63 @@ +package handlers + +import "testing" + +func TestIsValidExpoPushToken(t *testing.T) { + cases := []struct { + name string + token string + want bool + }{ + {name: "legacy token", token: "ExponentPushToken[abc123]", want: true}, + {name: "current token", token: "ExpoPushToken[abc123]", want: true}, + {name: "invalid token", token: "abc123", want: false}, + {name: "empty token", token: "", want: false}, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + got := isValidExpoPushToken(testCase.token) + if got != testCase.want { + t.Fatalf("isValidExpoPushToken(%q) = %v, want %v", testCase.token, got, testCase.want) + } + }) + } +} + +func TestShouldDeleteToken(t *testing.T) { + tests := []struct { + name string + body string + want bool + }{ + { + name: "device not registered details", + body: `{"data":{"status":"error","message":"The recipient device is not registered with FCM","details":{"error":"DeviceNotRegistered"}}}`, + want: true, + }, + { + name: "recipient message only", + body: `{"data":{"status":"error","message":"is not a registered push notification recipient","details":{"error":""}}}`, + want: true, + }, + { + name: "ok response", + body: `{"data":{"status":"ok"}}`, + want: false, + }, + { + name: "invalid response", + body: `not-json`, + want: false, + }, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + got := shouldDeleteToken([]byte(testCase.body)) + if got != testCase.want { + t.Fatalf("shouldDeleteToken(%s) = %v, want %v", testCase.name, got, testCase.want) + } + }) + } +} diff --git a/backend/api/internal/handlers/notifications_service.go b/backend/api/internal/handlers/notifications_service.go new file mode 100644 index 0000000..1815142 --- /dev/null +++ b/backend/api/internal/handlers/notifications_service.go @@ -0,0 +1,28 @@ +package handlers + +import ( + "backend/api/internal/database" +) + +func createAndPushNotification(userID int64, actorID int64, nType string, postID *int64, projectID *int64, commentID *int64, body string) { + notification, _, err := database.CreateNotification(database.NotificationInsert{ + UserID: userID, + ActorID: actorID, + Type: nType, + PostID: postID, + ProjectID: projectID, + CommentID: commentID, + }) + if err != nil || notification == nil { + return + } + + go SendNotificationPush(userID, notification, body) +} + +func notificationBody(actorName string, text string) string { + if actorName == "" { + return text + } + return actorName + " " + text +} diff --git a/backend/api/internal/handlers/post_routes.go b/backend/api/internal/handlers/post_routes.go index 20ec593..b29e8f2 100644 --- a/backend/api/internal/handlers/post_routes.go +++ b/backend/api/internal/handlers/post_routes.go @@ -6,7 +6,6 @@ import ( "strconv" "backend/api/internal/database" - "backend/api/internal/types" "github.com/gin-gonic/gin" ) @@ -58,12 +57,6 @@ func GetPostsByUserId(context *gin.Context) { RespondWithError(context, httpcode, fmt.Sprintf("Failed to fetch posts: %v", err)) return } - - if posts == nil { - RespondWithError(context, http.StatusNotFound, fmt.Sprintf("Posts from user with id %v not found", strId)) - return - } - context.JSON(http.StatusOK, posts) } @@ -86,24 +79,18 @@ func GetPostsByProjectId(context *gin.Context) { RespondWithError(context, httpcode, fmt.Sprintf("Failed to fetch posts: %v", err)) return } - - if posts == nil { - RespondWithError(context, http.StatusNotFound, fmt.Sprintf("Posts within project with id %v not found", strId)) - return - } - context.JSON(http.StatusOK, posts) } // CreatePost handles POST requests to create a new post -// It expects a JSON payload that can be bound to a `types.Post` object. +// It expects a JSON payload that can be bound to a `database.Post` object. // Validates the provided owner's ID and ensures the user and project exist. // Returns: // - 400 Bad Request if the JSON payload is invalid or the owner/project cannot be verified. // - 500 Internal Server Error if there is a database error. // On success, responds with a 201 Created status and the new post ID in JSON format. func CreatePost(context *gin.Context) { - var newPost types.Post + var newPost database.Post err := context.BindJSON(&newPost) if err != nil { @@ -111,30 +98,57 @@ func CreatePost(context *gin.Context) { return } + if len(newPost.Media) > 0 { + normalizedMedia, mediaErr := materializeMediaList(newPost.Media) + if mediaErr != nil { + RespondWithError(context, http.StatusBadRequest, "Invalid media reference") + return + } + newPost.Media = normalizedMedia + } + + authUserID, ok := GetAuthUserID(context) + if ok && authUserID != newPost.User { + RespondWithError(context, http.StatusForbidden, "Post user does not match auth user") + return + } + // verify the owner - username, err := database.GetUsernameById(newPost.User) + user, err := database.GetUserById(int(newPost.User)) if err != nil { RespondWithError(context, http.StatusBadRequest, fmt.Sprintf("Failed to verify post ownership: %v", err)) return } - if username == "" { - RespondWithError(context, http.StatusBadRequest, fmt.Sprintf("Failed to verify post ownership. User could not be found")) + if user == nil { + RespondWithError(context, http.StatusBadRequest, "Failed to verify post ownership. User could not be found") return } // verify the project - project, err := database.QueryPost(int(newPost.User)) + project, err := database.QueryProject(int(newPost.Project)) if err != nil { RespondWithError(context, http.StatusBadRequest, fmt.Sprintf("Failed to verify post ownership: %v", err)) return } if project == nil { - RespondWithError(context, http.StatusBadRequest, fmt.Sprintf("Failed to verify post ownership. Ownin project could not be found")) + RespondWithError(context, http.StatusBadRequest, "Failed to verify post ownership. Owning project could not be found") return } + if ok && project.Owner != authUserID { + isBuilder, err := database.QueryIsProjectBuilder(int(project.ID), authUserID) + if err != nil { + RespondWithError(context, http.StatusInternalServerError, fmt.Sprintf("Failed to check builder access: %v", err)) + return + } + if !isBuilder { + RespondWithError(context, http.StatusForbidden, "You are not a builder on this stream") + return + } + } + id, err := database.QueryCreatePost(&newPost) if err != nil { RespondWithError(context, http.StatusInternalServerError, fmt.Sprintf("Failed to create project: %v", err)) @@ -158,6 +172,22 @@ func DeletePost(context *gin.Context) { return } + post, err := database.QueryPost(id) + if err != nil { + RespondWithError(context, http.StatusInternalServerError, fmt.Sprintf("Failed to fetch post: %v", err)) + return + } + if post == nil { + RespondWithError(context, http.StatusNotFound, fmt.Sprintf("Post with id '%v' not found", id)) + return + } + + authUserID, ok := GetAuthUserID(context) + if ok && authUserID != post.User { + RespondWithError(context, http.StatusForbidden, "Forbidden") + return + } + httpCode, err := database.QueryDeletePost(id) // delete posts can return different errors... if err != nil { @@ -203,6 +233,12 @@ func UpdatePostInfo(context *gin.Context) { return } + authUserID, ok := GetAuthUserID(context) + if ok && authUserID != existingPost.User { + RespondWithError(context, http.StatusForbidden, "Forbidden") + return + } + // validate new owner if provided in update data if newOwner, ok := updateData["user"]; ok { ownerID, ok := newOwner.(float64) // Assuming JSON numbers are decoded as float64 @@ -210,8 +246,8 @@ func UpdatePostInfo(context *gin.Context) { RespondWithError(context, http.StatusBadRequest, "Invalid owner id format") return } - username, err := database.GetUsernameById(int64(ownerID)) - if err != nil || username == "" { + user, err := database.GetUserById(int(ownerID)) + if err != nil || user == nil { RespondWithError(context, http.StatusBadRequest, fmt.Sprintf("Invalid owner id: %v", ownerID)) return } @@ -241,6 +277,20 @@ func UpdatePostInfo(context *gin.Context) { } } + if rawMedia, exists := updatedData["media"]; exists { + mediaList, ok := coerceStringSlice(rawMedia) + if !ok { + RespondWithError(context, http.StatusBadRequest, "Invalid media format") + return + } + normalizedMedia, mediaErr := materializeMediaList(mediaList) + if mediaErr != nil { + RespondWithError(context, http.StatusBadRequest, "Invalid media reference") + return + } + updatedData["media"] = normalizedMedia + } + err = database.QueryUpdatePost(id, updatedData) if err != nil { RespondWithError(context, http.StatusInternalServerError, fmt.Sprintf("Error updating post: %v", err)) @@ -274,7 +324,7 @@ func LikePost(context *gin.Context) { RespondWithError(context, httpcode, fmt.Sprintf("Failed to like post: %v", err)) return } - context.JSON(http.StatusCreated, gin.H{"message": fmt.Sprintf("%v likes post %v", username, postId)}) + context.JSON(httpcode, gin.H{"message": fmt.Sprintf("%v likes post %v", username, postId)}) } // UnlikePost handles POST requests to unlike a post. @@ -291,7 +341,7 @@ func UnlikePost(context *gin.Context) { RespondWithError(context, httpcode, fmt.Sprintf("Failed to unlike post: %v", err)) return } - context.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("%v unliked post %v", username, postId)}) + context.JSON(httpcode, gin.H{"message": fmt.Sprintf("%v unliked post %v", username, postId)}) } // IsPostLiked handles GET requests to query for a post like. @@ -310,3 +360,74 @@ func IsPostLiked(context *gin.Context) { } context.JSON(httpcode, gin.H{"status": exists}) } + +// SavePost handles POST requests to save a post. +func SavePost(context *gin.Context) { + username := context.Param("username") + postId := context.Param("post_id") + + httpcode, err := database.QuerySavePost(username, postId) + if err != nil { + RespondWithError(context, httpcode, fmt.Sprintf("Failed to save post: %v", err)) + return + } + + postInt, _ := strconv.Atoi(postId) + post, _ := database.QueryPost(postInt) + actorID, _ := database.GetUserIdByUsername(username) + if post != nil { + postID64 := int64(post.ID) + createAndPushNotification( + int64(post.User), + int64(actorID), + "save_post", + &postID64, + nil, + nil, + notificationBody(username, "saved your byte"), + ) + } + + context.JSON(httpcode, gin.H{"message": fmt.Sprintf("%v saved post %v", username, postId)}) +} + +// UnsavePost handles POST requests to unsave a post. +func UnsavePost(context *gin.Context) { + username := context.Param("username") + postId := context.Param("post_id") + + httpcode, err := database.QueryUnsavePost(username, postId) + if err != nil { + RespondWithError(context, httpcode, fmt.Sprintf("Failed to unsave post: %v", err)) + return + } + + postInt, _ := strconv.Atoi(postId) + post, _ := database.QueryPost(postInt) + actorID, _ := database.GetUserIdByUsername(username) + if post != nil { + postID64 := int64(post.ID) + _, _ = database.DeleteNotificationByReference( + int64(post.User), + int64(actorID), + "save_post", + &postID64, + nil, + ) + } + + context.JSON(httpcode, gin.H{"message": fmt.Sprintf("%v unsaved post %v", username, postId)}) +} + +// GetSavedPosts handles GET requests to list saved posts for a user. +func GetSavedPosts(context *gin.Context) { + username := context.Param("username") + + posts, httpcode, err := database.QuerySavedPostsByUser(username) + if err != nil { + RespondWithError(context, httpcode, fmt.Sprintf("Failed to fetch saved posts: %v", err)) + return + } + + context.JSON(http.StatusOK, posts) +} diff --git a/backend/api/internal/handlers/profile_picture_routes.go b/backend/api/internal/handlers/profile_picture_routes.go new file mode 100644 index 0000000..88fb605 --- /dev/null +++ b/backend/api/internal/handlers/profile_picture_routes.go @@ -0,0 +1,105 @@ +package handlers + +import ( + "fmt" + "mime" + "net/http" + "os" + "path/filepath" + "strings" + + "backend/api/internal/database" + "backend/api/internal/logger" + + "github.com/gin-gonic/gin" +) + +func UpdateProfilePicture(context *gin.Context) { + username := context.Param("username") + + existingUser, err := database.GetUserByUsername(username) + if err != nil { + RespondWithError(context, http.StatusInternalServerError, fmt.Sprintf("Error fetching user: %v", err)) + return + } + if existingUser == nil { + RespondWithError(context, http.StatusNotFound, fmt.Sprintf("User with name '%v' not found", username)) + return + } + + oldPicture := strings.TrimSpace(existingUser.Picture) + + ct := context.Request.Header.Get("Content-Type") + if ct == "" || !strings.Contains(strings.ToLower(ct), "multipart/form-data") { + logger.Log.WithFields(map[string]interface{}{ + "content_type": ct, + "content_length": context.Request.ContentLength, + }).Warn("UpdateProfilePicture rejected – expected multipart/form-data") + context.JSON(http.StatusBadRequest, gin.H{ + "error": "Bad Request", + "message": "Content-Type must be multipart/form-data", + }) + return + } + + file, err := context.FormFile("file") + if err != nil { + file, err = context.FormFile("picture") + if err != nil { + file, err = context.FormFile("image") + if err != nil { + logger.Log.WithFields(map[string]interface{}{ + "content_type": ct, + "content_length": context.Request.ContentLength, + "error": err.Error(), + }).Warn("UpdateProfilePicture missing file") + context.JSON(http.StatusBadRequest, gin.H{ + "error": "Bad Request", + "message": fmt.Sprintf("Missing profile picture file: %v", err), + }) + return + } + } + } + + if err := os.MkdirAll(uploadDir, 0o755); err != nil { + RespondWithError(context, http.StatusInternalServerError, "Failed to prepare upload directory") + return + } + + ext := strings.ToLower(filepath.Ext(file.Filename)) + if ext == "" { + if file.Header.Get("Content-Type") != "" { + if guessed, guessErr := mime.ExtensionsByType(file.Header.Get("Content-Type")); guessErr == nil && len(guessed) > 0 { + ext = guessed[0] + } + } + } + + randomName, err := randomHex(12) + if err != nil { + RespondWithError(context, http.StatusInternalServerError, "Failed to generate filename") + return + } + + filename := buildManagedUploadFilename(context, randomName, ext) + storedPath := filepath.Join(uploadDir, filename) + if err := context.SaveUploadedFile(file, storedPath); err != nil { + RespondWithError(context, http.StatusInternalServerError, "Failed to store profile picture") + return + } + + existingUser.Picture = fmt.Sprintf("/%s/%s", uploadDir, filename) + if err := database.UpdateUser(existingUser); err != nil { + _ = os.Remove(storedPath) + RespondWithError(context, http.StatusInternalServerError, fmt.Sprintf("Error updating user: %v", err)) + return + } + + cleanupReplacedProfileUpload(oldPicture, existingUser.Picture) + + context.JSON(http.StatusOK, gin.H{ + "message": "Profile picture updated successfully.", + "user": existingUser, + }) +} diff --git a/backend/api/internal/handlers/project_routes.go b/backend/api/internal/handlers/project_routes.go index 6e58118..7a0b5f5 100644 --- a/backend/api/internal/handlers/project_routes.go +++ b/backend/api/internal/handlers/project_routes.go @@ -6,7 +6,6 @@ import ( "strconv" "backend/api/internal/database" - "backend/api/internal/types" "github.com/gin-gonic/gin" ) @@ -56,24 +55,179 @@ func GetProjectsByUserId(context *gin.Context) { RespondWithError(context, httpcode, fmt.Sprintf("Failed to fetch projects: %v", err)) return } + context.JSON(http.StatusOK, project) +} + +// GetProjectsByBuilderId handles GET requests to retrieve projects a user can build. +// It expects the `user_id` parameter in the URL. +// Returns: +// - 400 Bad Request if the ID is invalid. +// - 403 Forbidden if auth user does not match. +// - 500 Internal Server Error if the database query fails. +// On success, responds with a 200 OK status and the projects' details in JSON format. +func GetProjectsByBuilderId(context *gin.Context) { + strId := context.Param("user_id") + userId, err := strconv.Atoi(strId) + if err != nil { + RespondWithError(context, http.StatusBadRequest, fmt.Sprintf("Failed to parse user_id: %v", err)) + return + } + + authUserID, ok := GetAuthUserID(context) + if ok && int64(userId) != authUserID { + RespondWithError(context, http.StatusForbidden, "Forbidden") + return + } + projects, httpcode, err := database.QueryProjectsByBuilderId(userId) + if err != nil { + RespondWithError(context, httpcode, fmt.Sprintf("Failed to fetch projects: %v", err)) + return + } + context.JSON(http.StatusOK, projects) +} + +// GetProjectBuilders handles GET requests to retrieve a project's builders. +func GetProjectBuilders(context *gin.Context) { + strId := context.Param("project_id") + projectId, err := strconv.Atoi(strId) + if err != nil { + RespondWithError(context, http.StatusBadRequest, fmt.Sprintf("Failed to parse project_id: %v", err)) + return + } + + builders, httpcode, err := database.QueryProjectBuilders(projectId) + if err != nil { + RespondWithError(context, httpcode, fmt.Sprintf("Failed to fetch builders: %v", err)) + return + } + + context.JSON(http.StatusOK, builders) +} + +// AddProjectBuilder handles POST requests to add a builder by username. +func AddProjectBuilder(context *gin.Context) { + projectId, err := strconv.Atoi(context.Param("project_id")) + if err != nil { + RespondWithError(context, http.StatusBadRequest, fmt.Sprintf("Failed to parse project_id: %v", err)) + return + } + + builderUsername := context.Param("username") + if builderUsername == "" { + RespondWithError(context, http.StatusBadRequest, "Missing builder username") + return + } + + project, err := database.QueryProject(projectId) + if err != nil { + RespondWithError(context, http.StatusInternalServerError, fmt.Sprintf("Failed to load project: %v", err)) + return + } if project == nil { - RespondWithError(context, httpcode, fmt.Sprintf("User with id '%v' not found", strId)) + RespondWithError(context, http.StatusNotFound, "Project not found") return } - context.JSON(http.StatusOK, project) + authUserID, ok := GetAuthUserID(context) + if !ok || project.Owner != authUserID { + RespondWithError(context, http.StatusForbidden, "Only the owner can manage builders") + return + } + + builder, err := database.GetUserByUsername(builderUsername) + if err != nil || builder == nil { + RespondWithError(context, http.StatusBadRequest, "Builder user not found") + return + } + + builderID64 := int64(builder.Id) + if builderID64 == project.Owner { + context.JSON(http.StatusOK, gin.H{"message": "Owner already has access"}) + return + } + + status, err := database.QueryAddProjectBuilder(projectId, builderID64) + if err != nil { + RespondWithError(context, status, fmt.Sprintf("Failed to add builder: %v", err)) + return + } + + projectID64 := int64(projectId) + owner, _ := database.GetUserById(int(project.Owner)) + createAndPushNotification( + builderID64, + project.Owner, + "builder_added", + nil, + &projectID64, + nil, + notificationBody(owner.Username, "added you as a builder"), + ) + + context.JSON(http.StatusOK, gin.H{"message": "Builder added"}) +} + +// RemoveProjectBuilder handles DELETE requests to remove a builder by username. +func RemoveProjectBuilder(context *gin.Context) { + projectId, err := strconv.Atoi(context.Param("project_id")) + if err != nil { + RespondWithError(context, http.StatusBadRequest, fmt.Sprintf("Failed to parse project_id: %v", err)) + return + } + + builderUsername := context.Param("username") + if builderUsername == "" { + RespondWithError(context, http.StatusBadRequest, "Missing builder username") + return + } + + project, err := database.QueryProject(projectId) + if err != nil { + RespondWithError(context, http.StatusInternalServerError, fmt.Sprintf("Failed to load project: %v", err)) + return + } + if project == nil { + RespondWithError(context, http.StatusNotFound, "Project not found") + return + } + + builder, err := database.GetUserByUsername(builderUsername) + if err != nil || builder == nil { + RespondWithError(context, http.StatusBadRequest, "Builder user not found") + return + } + + authUserID, ok := GetAuthUserID(context) + if !ok { + RespondWithError(context, http.StatusForbidden, "Forbidden") + return + } + + builderID64 := int64(builder.Id) + if project.Owner != authUserID && builderID64 != authUserID { + RespondWithError(context, http.StatusForbidden, "Only the owner can manage builders") + return + } + + status, err := database.QueryRemoveProjectBuilder(projectId, builderID64) + if err != nil { + RespondWithError(context, status, fmt.Sprintf("Failed to remove builder: %v", err)) + return + } + + context.JSON(http.StatusOK, gin.H{"message": "Builder removed"}) } // CreateProject handles POST requests to create a new project. -// It expects a JSON payload that can be bound to a `types.Project` object. +// It expects a JSON payload that can be bound to a `database.Project` object. // Validates the provided owner's ID and ensures the user exists. // Returns: // - 400 Bad Request if the JSON payload is invalid or the owner cannot be verified. // - 500 Internal Server Error if there is a database error. // On success, responds with a 201 Created status and the new project ID in JSON format. func CreateProject(context *gin.Context) { - var newProj types.Project + var newProj database.Project err := context.BindJSON(&newProj) if err != nil { @@ -82,14 +236,20 @@ func CreateProject(context *gin.Context) { } // verify the owner - username, err := database.GetUsernameById(newProj.Owner) + user, err := database.GetUserById(int(newProj.Owner)) if err != nil { RespondWithError(context, http.StatusBadRequest, fmt.Sprintf("Failed to verify project ownership: %v", err)) return } - if username == "" { - RespondWithError(context, http.StatusBadRequest, fmt.Sprintf("Failed to verify project ownership. User could not be found")) + if user == nil { + RespondWithError(context, http.StatusBadRequest, "Failed to verify project ownership. User could not be found") + return + } + + authUserID, ok := GetAuthUserID(context) + if ok && authUserID != newProj.Owner { + RespondWithError(context, http.StatusForbidden, "Owner does not match auth user") return } @@ -116,6 +276,22 @@ func DeleteProject(context *gin.Context) { return } + project, err := database.QueryProject(id) + if err != nil { + RespondWithError(context, http.StatusInternalServerError, fmt.Sprintf("Failed to retrieve project: %v", err)) + return + } + if project == nil { + RespondWithError(context, http.StatusNotFound, fmt.Sprintf("Project with id '%v' not found", id)) + return + } + + authUserID, ok := GetAuthUserID(context) + if ok && authUserID != project.Owner { + RespondWithError(context, http.StatusForbidden, "Only the owner can delete a stream") + return + } + httpCode, err := database.QueryDeleteProject(id) // delete projects can return different errors... if err != nil { @@ -163,6 +339,19 @@ func UpdateProjectInfo(context *gin.Context) { return } + authUserID, ok := GetAuthUserID(context) + if ok && authUserID != existingProj.Owner { + isBuilder, err := database.QueryIsProjectBuilder(id, authUserID) + if err != nil { + RespondWithError(context, http.StatusInternalServerError, "Failed to verify builder permissions") + return + } + if !isBuilder { + RespondWithError(context, http.StatusForbidden, "Forbidden") + return + } + } + // Validate new owner if provided in update data if newOwner, ok := updateData["owner"]; ok { ownerID, ok := newOwner.(float64) // Assuming JSON numbers are decoded as float64 @@ -170,8 +359,8 @@ func UpdateProjectInfo(context *gin.Context) { RespondWithError(context, http.StatusBadRequest, "Invalid owner id format") return } - username, err := database.GetUsernameById(int64(ownerID)) - if err != nil || username == "" { + user, err := database.GetUserById(int(ownerID)) + if err != nil || user == nil { RespondWithError(context, http.StatusBadRequest, fmt.Sprintf("Invalid owner id: %v", ownerID)) return } @@ -302,6 +491,22 @@ func FollowProject(context *gin.Context) { RespondWithError(context, httpcode, fmt.Sprintf("Failed to add follower: %v", err)) return } + + projectInt, _ := strconv.Atoi(projectId) + project, _ := database.QueryProject(projectInt) + actor, _ := database.GetUserByUsername(username) + if project != nil { + projectID64 := int64(project.ID) + createAndPushNotification( + project.Owner, + int64(actor.Id), + "save_project", + nil, + &projectID64, + nil, + notificationBody(username, "saved your stream"), + ) + } context.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("%v now follows project %v", username, projectId)}) } @@ -319,6 +524,20 @@ func UnfollowProject(context *gin.Context) { RespondWithError(context, httpcode, fmt.Sprintf("Failed to remove follower: %v", err)) return } + + projectInt, _ := strconv.Atoi(projectId) + project, _ := database.QueryProject(projectInt) + actor, _ := database.GetUserByUsername(username) + if project != nil { + projectID64 := int64(project.ID) + _, _ = database.DeleteNotificationByReference( + project.Owner, + int64(actor.Id), + "save_project", + nil, + &projectID64, + ) + } context.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("%v unfollowed project %v", username, projectId)}) } diff --git a/backend/api/internal/handlers/user_routes.go b/backend/api/internal/handlers/user_routes.go index 31046d1..6ce9a22 100644 --- a/backend/api/internal/handlers/user_routes.go +++ b/backend/api/internal/handlers/user_routes.go @@ -1,11 +1,19 @@ package handlers import ( + "database/sql" + "encoding/json" "fmt" "net/http" + "net/url" + "os" + "path/filepath" + "sort" + "strconv" + "strings" "backend/api/internal/database" - "backend/api/internal/types" + "backend/api/internal/logger" "github.com/gin-gonic/gin" ) @@ -20,7 +28,7 @@ import ( func GetUsernameById(context *gin.Context) { username := context.Param("username") - user, err := database.QueryUsername(username) + user, err := database.GetUserByUsername(username) if err != nil { RespondWithError(context, http.StatusInternalServerError, "Failed to fetch user") return @@ -44,7 +52,7 @@ func GetUsernameById(context *gin.Context) { func GetUserByUsername(context *gin.Context) { username := context.Param("username") - user, err := database.QueryUsername(username) + user, err := database.GetUserByUsername(username) if err != nil { RespondWithError(context, http.StatusInternalServerError, fmt.Sprintf("Failed to get user: %v", err)) return @@ -58,6 +66,75 @@ func GetUserByUsername(context *gin.Context) { context.JSON(http.StatusOK, user) } +// GetUserById handles GET requests to fetch a user by their ID. +// It expects the `user_id` parameter in the URL. +// Returns: +// - 400 Bad Request if the user ID is invalid. +// - 404 Not Found if no user is found with the given ID. +// - 500 Internal Server Error if a database query fails. +// On success, responds with a 200 OK status and the user data in JSON format. +func GetUserById(context *gin.Context) { + strId := context.Param("user_id") + userId, err := strconv.Atoi(strId) + if err != nil { + RespondWithError(context, http.StatusBadRequest, fmt.Sprintf("Failed to parse user id: %v", err)) + return + } + + user, err := database.GetUserById(userId) + if err != nil { + RespondWithError(context, http.StatusInternalServerError, fmt.Sprintf("Failed to get user: %v", err)) + return + } + + if user == nil { + RespondWithError(context, http.StatusNotFound, fmt.Sprintf("User with id '%v' not found", userId)) + return + } + + context.JSON(http.StatusOK, user) +} + +// GetUsers handles GET requests to fetch all users. +// Returns: +// - 500 Internal Server Error if a database query fails. +// On success, responds with a 200 OK status and the users list in JSON format. +func GetUsers(context *gin.Context) { + // strStart := context.Query("start") + // strCount := context.Query("count") + // start := 0 + // count := 50 + + // if strStart != "" { + // parsed, err := strconv.Atoi(strStart) + // if err != nil || parsed < 0 { + // RespondWithError(context, http.StatusBadRequest, "Start must be a non-negative integer") + // return + // } + // start = parsed + // } + + // if strCount != "" { + // parsed, err := strconv.Atoi(strCount) + // if err != nil || parsed <= 0 { + // RespondWithError(context, http.StatusBadRequest, "Count must be a positive integer") + // return + // } + // if parsed > 100 { + // parsed = 100 + // } + // count = parsed + // } + + users, err := database.GetUsers() + if err != nil { + RespondWithError(context, http.StatusInternalServerError, fmt.Sprintf("Failed to fetch users: %v", err)) + return + } + + context.JSON(http.StatusOK, users) +} + // CreateUser handles POST requests to create a new user. // It expects a JSON body with the user details. // Returns: @@ -65,14 +142,14 @@ func GetUserByUsername(context *gin.Context) { // - 500 Internal Server Error if an error occurs while creating the user. // On success, responds with a 201 Created status and a message confirming the user creation. func CreateUser(context *gin.Context) { - var newUser types.User + var newUser database.ApiUser err := context.BindJSON(&newUser) if err != nil { RespondWithError(context, http.StatusBadRequest, fmt.Sprintf("Failed to bind to JSON: %v", err)) return } - err = database.QueryCreateUser(&newUser) + _, err = database.CreateUser(&newUser) if err != nil { RespondWithError(context, http.StatusInternalServerError, fmt.Sprintf("Failed to create user: %v", err)) return @@ -89,12 +166,385 @@ func CreateUser(context *gin.Context) { // On success, responds with a 200 OK status and a message confirming the user deletion. func DeleteUser(context *gin.Context) { username := context.Param("username") - httpCode, err := database.QueryDeleteUser(username) + existingUser, err := database.GetUserByUsername(username) + if err != nil { + RespondWithError(context, http.StatusInternalServerError, fmt.Sprintf("Failed to resolve user before delete: %v", err)) + return + } + if existingUser == nil { + RespondWithError(context, http.StatusNotFound, fmt.Sprintf("User '%v' not found.", username)) + return + } + + managedUploads, err := collectManagedUploadsForUser(existingUser.Id) + if err != nil { + RespondWithError(context, http.StatusInternalServerError, fmt.Sprintf("Failed to collect user media before delete: %v", err)) + return + } + + err = database.DeleteUser(username) + if err != nil { + RespondWithError(context, http.StatusInternalServerError, fmt.Sprintf("Failed to delete user: %v", err)) + return + } + + removedFiles := removeManagedUploadFiles(managedUploads) + removedOwnedOrphans := removeOwnerPrefixedUploadFiles(existingUser.Id, managedUploads) + context.JSON(http.StatusOK, gin.H{ + "message": fmt.Sprintf("User '%v' deleted.", username), + "removed_uploads": removedFiles, + "removed_orphan_uploads": removedOwnedOrphans, + }) +} + +type managedMediaItem struct { + Filename string `json:"filename"` + URL string `json:"url"` +} + +func GetUserManagedMedia(context *gin.Context) { + username := context.Param("username") + existingUser, err := database.GetUserByUsername(username) + if err != nil { + RespondWithError(context, http.StatusInternalServerError, fmt.Sprintf("Failed to resolve user: %v", err)) + return + } + if existingUser == nil { + RespondWithError(context, http.StatusNotFound, fmt.Sprintf("User '%v' not found.", username)) + return + } + + referenced, err := collectManagedUploadsForUser(existingUser.Id) + if err != nil { + RespondWithError(context, http.StatusInternalServerError, fmt.Sprintf("Failed to collect media references: %v", err)) + return + } + + owned := collectOwnerPrefixedUploadFilenames(existingUser.Id) + for filename := range owned { + referenced[filename] = struct{}{} + } + + items := make([]managedMediaItem, 0, len(referenced)) + for filename := range referenced { + items = append(items, managedMediaItem{ + Filename: filename, + URL: fmt.Sprintf("/%s/%s", uploadDir, filename), + }) + } + + sort.Slice(items, func(i, j int) bool { + return items[i].Filename < items[j].Filename + }) + + context.JSON(http.StatusOK, gin.H{ + "items": items, + }) +} + +type deleteManagedMediaRequest struct { + Filenames []string `json:"filenames"` + DeleteAll bool `json:"deleteAll"` +} + +func DeleteUserManagedMedia(context *gin.Context) { + username := context.Param("username") + existingUser, err := database.GetUserByUsername(username) + if err != nil { + RespondWithError(context, http.StatusInternalServerError, fmt.Sprintf("Failed to resolve user: %v", err)) + return + } + if existingUser == nil { + RespondWithError(context, http.StatusNotFound, fmt.Sprintf("User '%v' not found.", username)) + return + } + + var payload deleteManagedMediaRequest + if err := context.BindJSON(&payload); err != nil { + RespondWithError(context, http.StatusBadRequest, fmt.Sprintf("Invalid request payload: %v", err)) + return + } + + referenced, err := collectManagedUploadsForUser(existingUser.Id) if err != nil { - RespondWithError(context, int(httpCode), fmt.Sprintf("Failed to delete user: %v", err)) + RespondWithError(context, http.StatusInternalServerError, fmt.Sprintf("Failed to collect media references: %v", err)) return } - context.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("User '%v' deleted.", username)}) + owned := collectOwnerPrefixedUploadFilenames(existingUser.Id) + for filename := range owned { + referenced[filename] = struct{}{} + } + + targets := make(map[string]struct{}) + if payload.DeleteAll { + for filename := range referenced { + targets[filename] = struct{}{} + } + } else { + for _, raw := range payload.Filenames { + filename := strings.TrimSpace(raw) + if filename == "" || strings.Contains(filename, "/") || strings.Contains(filename, `\\`) { + continue + } + if _, ok := referenced[filename]; ok { + targets[filename] = struct{}{} + } + } + } + + if len(targets) == 0 { + context.JSON(http.StatusOK, gin.H{ + "message": "No matching media selected.", + "removed": 0, + }) + return + } + + if err := removeMediaReferencesForUser(existingUser.Id, targets); err != nil { + RespondWithError(context, http.StatusInternalServerError, fmt.Sprintf("Failed to remove media references: %v", err)) + return + } + + removedFiles := removeManagedUploadFiles(targets) + context.JSON(http.StatusOK, gin.H{ + "message": "Media removed successfully.", + "removed": removedFiles, + }) +} + +func collectManagedUploadsForUser(userID int) (map[string]struct{}, error) { + uploads := make(map[string]struct{}) + + addFromPath := func(path string) { + if filename, ok := extractManagedUploadFilename(path); ok { + uploads[filename] = struct{}{} + } + } + + addFromJSON := func(raw []byte) { + if len(raw) == 0 { + return + } + var media []string + if err := json.Unmarshal(raw, &media); err != nil { + return + } + for _, item := range media { + addFromPath(item) + } + } + + var picture sql.NullString + if err := database.DB.QueryRow("SELECT picture FROM users WHERE id = $1", userID).Scan(&picture); err == nil && picture.Valid { + addFromPath(picture.String) + } + + queries := []string{ + "SELECT COALESCE(media, '[]') FROM projects WHERE owner = $1", + `SELECT COALESCE(p.media, '[]') + FROM posts p + WHERE p.user_id = $1 OR p.project_id IN (SELECT id FROM projects WHERE owner = $1)`, + `SELECT COALESCE(c.media, '[]') + FROM comments c + WHERE c.user_id = $1 OR c.id IN ( + SELECT pc.comment_id + FROM postcomments pc + JOIN posts p ON p.id = pc.post_id + WHERE p.user_id = $1 OR p.project_id IN (SELECT id FROM projects WHERE owner = $1) + UNION + SELECT prc.comment_id + FROM projectcomments prc + JOIN projects pr ON pr.id = prc.project_id + WHERE pr.owner = $1 + )`, + } + + for _, query := range queries { + rows, err := database.DB.Query(query, userID) + if err != nil { + return nil, err + } + + for rows.Next() { + var raw []byte + if scanErr := rows.Scan(&raw); scanErr != nil { + rows.Close() + return nil, scanErr + } + addFromJSON(raw) + } + if err := rows.Err(); err != nil { + rows.Close() + return nil, err + } + rows.Close() + } + + return uploads, nil +} + +func removeManagedUploadFiles(uploads map[string]struct{}) int { + removed := 0 + for filename := range uploads { + filePath := filepath.Join(uploadDir, filename) + if err := os.Remove(filePath); err != nil { + if os.IsNotExist(err) { + continue + } + logger.Log.WithFields(map[string]interface{}{ + "path": filePath, + "err": err.Error(), + }).Warn("Failed to remove managed upload after user delete") + continue + } + removed += 1 + } + return removed +} + +func collectOwnerPrefixedUploadFilenames(userID int) map[string]struct{} { + result := make(map[string]struct{}) + prefix := fmt.Sprintf("u%d_", userID) + + entries, err := os.ReadDir(uploadDir) + if err != nil { + return result + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if strings.HasPrefix(name, prefix) { + result[name] = struct{}{} + } + } + + return result +} + +func removeMediaReferencesForUser(userID int, targets map[string]struct{}) error { + if len(targets) == 0 { + return nil + } + + var picture sql.NullString + if err := database.DB.QueryRow("SELECT picture FROM users WHERE id = $1", userID).Scan(&picture); err == nil && picture.Valid { + if filename, ok := extractManagedUploadFilename(picture.String); ok { + if _, shouldRemove := targets[filename]; shouldRemove { + if _, updateErr := database.DB.Exec("UPDATE users SET picture = '' WHERE id = $1", userID); updateErr != nil { + return updateErr + } + } + } + } + + if err := pruneMediaColumnRows("projects", "owner", userID, targets); err != nil { + return err + } + if err := pruneMediaColumnRows("posts", "user_id", userID, targets); err != nil { + return err + } + if err := pruneMediaColumnRows("comments", "user_id", userID, targets); err != nil { + return err + } + + return nil +} + +func pruneMediaColumnRows(tableName, userColumn string, userID int, targets map[string]struct{}) error { + query := fmt.Sprintf("SELECT id, COALESCE(media, '[]') FROM %s WHERE %s = $1", tableName, userColumn) + rows, err := database.DB.Query(query, userID) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var id int + var raw []byte + if scanErr := rows.Scan(&id, &raw); scanErr != nil { + return scanErr + } + + var media []string + if unmarshalErr := json.Unmarshal(raw, &media); unmarshalErr != nil { + continue + } + + filtered := make([]string, 0, len(media)) + changed := false + for _, item := range media { + filename, managed := extractManagedUploadFilename(item) + if managed { + if _, shouldRemove := targets[filename]; shouldRemove { + changed = true + continue + } + } + filtered = append(filtered, item) + } + + if !changed { + continue + } + + payload, marshalErr := json.Marshal(filtered) + if marshalErr != nil { + return marshalErr + } + + updateQuery := fmt.Sprintf("UPDATE %s SET media = $1 WHERE id = $2", tableName) + if _, execErr := database.DB.Exec(updateQuery, payload, id); execErr != nil { + return execErr + } + } + + return rows.Err() +} + +func removeOwnerPrefixedUploadFiles(userID int, alreadyConsidered map[string]struct{}) int { + prefix := fmt.Sprintf("u%d_", userID) + entries, err := os.ReadDir(uploadDir) + if err != nil { + logger.Log.WithFields(map[string]interface{}{ + "dir": uploadDir, + "err": err.Error(), + }).Warn("Failed to scan uploads directory for owner-prefixed cleanup") + return 0 + } + + removed := 0 + for _, entry := range entries { + if entry.IsDir() { + continue + } + + name := entry.Name() + if !strings.HasPrefix(name, prefix) { + continue + } + if _, exists := alreadyConsidered[name]; exists { + continue + } + + filePath := filepath.Join(uploadDir, name) + if err := os.Remove(filePath); err != nil { + if os.IsNotExist(err) { + continue + } + logger.Log.WithFields(map[string]interface{}{ + "path": filePath, + "err": err.Error(), + }).Warn("Failed to remove owner-prefixed orphan upload after user delete") + continue + } + + removed += 1 + } + + return removed } // UpdateUserInfo handles PUT requests to update a user's information. @@ -119,7 +569,7 @@ func UpdateUserInfo(context *gin.Context) { return } - existingUser, err := database.QueryUsername(username) + existingUser, err := database.GetUserByUsername(username) if err != nil { RespondWithError(context, http.StatusInternalServerError, fmt.Sprintf("Error fetching user: %v", err)) return @@ -130,6 +580,8 @@ func UpdateUserInfo(context *gin.Context) { return } + oldPicture := strings.TrimSpace(existingUser.Picture) + updatedData := make(map[string]interface{}) // Iterate through the fields of the existing user and map the request data to those fields @@ -142,28 +594,135 @@ func UpdateUserInfo(context *gin.Context) { return } } - err = database.QueryUpdateUser(username, updatedData) + + if picture, ok := updatedData["picture"]; ok { + pictureStr, parseOK := picture.(string) + if !parseOK { + RespondWithError(context, http.StatusBadRequest, "Invalid picture format") + return + } + if strings.TrimSpace(pictureStr) == "" { + existingUser.Picture = "" + } else { + storedPicture, ingestErr := materializeMediaReference(pictureStr) + if ingestErr != nil { + RespondWithError(context, http.StatusBadRequest, "Invalid picture media reference") + return + } + existingUser.Picture = storedPicture + } + } + + if bio, ok := updatedData["bio"]; ok { + bioStr, parseOK := bio.(string) + if !parseOK { + RespondWithError(context, http.StatusBadRequest, "Invalid bio format") + return + } + existingUser.Bio = bioStr + } + + if links, ok := updatedData["links"]; ok { + linksMap, parseOK := links.(map[string]interface{}) + if parseOK { + existingUser.Links = linksMap + } else if linksArray, parseArray := links.([]interface{}); parseArray { + normalized := make(map[string]interface{}, len(linksArray)) + for index, item := range linksArray { + link, isString := item.(string) + if !isString { + RespondWithError(context, http.StatusBadRequest, "Invalid links format") + return + } + normalized[fmt.Sprintf("link_%d", index)] = link + } + existingUser.Links = normalized + } else { + RespondWithError(context, http.StatusBadRequest, "Invalid links format") + return + } + } + + if settings, ok := updatedData["settings"]; ok { + settingsMap, parseOK := settings.(map[string]interface{}) + if !parseOK { + RespondWithError(context, http.StatusBadRequest, "Invalid settings format") + return + } + existingUser.Settings = settingsMap + } + + if usernameValue, ok := updatedData["username"]; ok { + usernameStr, parseOK := usernameValue.(string) + if !parseOK { + RespondWithError(context, http.StatusBadRequest, "Invalid username format") + return + } + if usernameStr != "" && usernameStr != existingUser.Username { + RespondWithError(context, http.StatusBadRequest, "Username updates are not supported") + return + } + } + + err = database.UpdateUser(existingUser) if err != nil { RespondWithError(context, http.StatusInternalServerError, fmt.Sprintf("Error updating user: %v", err)) return } - var validUser *types.User - newUsername, usernameExists := updatedData["username"] - usernameStr, parseOk := newUsername.(string) + cleanupReplacedProfileUpload(oldPicture, existingUser.Picture) - // if there is a new username provided, ensure it is not empty - if usernameExists && parseOk && usernameStr != "" { - validUser, err = database.QueryUsername(usernameStr) - } else { - validUser, err = database.QueryUsername(username) - } + validUser, err := database.GetUserByUsername(existingUser.Username) if err != nil { RespondWithError(context, http.StatusInternalServerError, fmt.Sprintf("Error validating updated data: %v", err)) } context.JSON(http.StatusOK, gin.H{"message": "User updated successfully.", "user": validUser}) } +func cleanupReplacedProfileUpload(previousPicture, nextPicture string) { + previousFilename, previousManaged := extractManagedUploadFilename(previousPicture) + if !previousManaged { + return + } + + nextFilename, nextManaged := extractManagedUploadFilename(nextPicture) + if nextManaged && strings.EqualFold(previousFilename, nextFilename) { + return + } + + filePath := filepath.Join(uploadDir, previousFilename) + if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) { + logger.Log.WithFields(map[string]interface{}{ + "path": filePath, + "err": err.Error(), + }).Warn("Failed to remove replaced profile image") + } +} + +func extractManagedUploadFilename(picture string) (string, bool) { + trimmed := strings.TrimSpace(picture) + if trimmed == "" { + return "", false + } + + pathValue := trimmed + if parsed, err := url.Parse(trimmed); err == nil && parsed.Scheme != "" { + pathValue = parsed.Path + } + + normalized := strings.TrimPrefix(pathValue, "/") + if !strings.HasPrefix(normalized, uploadDir+"/") { + return "", false + } + + filename := strings.TrimPrefix(normalized, uploadDir+"/") + if filename == "" || strings.Contains(filename, "/") || strings.Contains(filename, `\\`) { + return "", false + } + + return filename, true +} + // GetUsersFollowers handles GET requests to fetch the list of user IDs who follow the specified user. // It expects the `username` parameter in the URL. // Returns: @@ -173,9 +732,9 @@ func UpdateUserInfo(context *gin.Context) { func GetUsersFollowers(context *gin.Context) { username := context.Param("username") - followers, httpcode, err := database.QueryGetUsersFollowers(username) + followers, err := database.GetUserFollowers(username) if err != nil { - RespondWithError(context, httpcode, fmt.Sprintf("Failed to fetch followers: %v", err)) + RespondWithError(context, http.StatusInternalServerError, fmt.Sprintf("Failed to fetch followers: %v", err)) return } @@ -191,9 +750,9 @@ func GetUsersFollowers(context *gin.Context) { func GetUsersFollowing(context *gin.Context) { username := context.Param("username") - following, httpcode, err := database.QueryGetUsersFollowing(username) + following, err := database.GetUserFollowing(username) if err != nil { - RespondWithError(context, httpcode, fmt.Sprintf("Failed to fetch following: %v", err)) + RespondWithError(context, http.StatusInternalServerError, fmt.Sprintf("Failed to fetch following: %v", err)) return } @@ -209,13 +768,13 @@ func GetUsersFollowing(context *gin.Context) { func GetUsersFollowersUsernames(context *gin.Context) { username := context.Param("username") - followers, httpcode, err := database.QueryGetUsersFollowersUsernames(username) + followers, err := database.GetUserFollowersUsernames(username) if err != nil { - RespondWithError(context, httpcode, fmt.Sprintf("Failed to fetch followers: %v", err)) + RespondWithError(context, http.StatusInternalServerError, fmt.Sprintf("Failed to fetch followers: %v", err)) return } - context.JSON(http.StatusOK, gin.H{"message": "Successfully got followers", "followers":followers}) + context.JSON(http.StatusOK, gin.H{"message": "Successfully got followers", "followers": followers}) } // GetUsersFollowingUsernames handles GET requests to fetch the usernames of users whom the specified user follows. @@ -227,13 +786,13 @@ func GetUsersFollowersUsernames(context *gin.Context) { func GetUsersFollowingUsernames(context *gin.Context) { username := context.Param("username") - following, httpcode, err := database.QueryGetUsersFollowingUsernames(username) + following, err := database.GetUserFollowingUsernames(username) if err != nil { - RespondWithError(context, httpcode, fmt.Sprintf("Failed to fetch following: %v", err)) + RespondWithError(context, http.StatusInternalServerError, fmt.Sprintf("Failed to fetch following: %v", err)) return } - context.JSON(http.StatusOK, gin.H{"message": "Successfully got following", "following": following}) + context.JSON(http.StatusOK, gin.H{"message": "Successfully got following", "following": following}) } // FollowUser handles POST requests to create a follow relationship between a user and another user. @@ -246,11 +805,23 @@ func FollowUser(context *gin.Context) { username := context.Param("username") newFollow := context.Param("new_follow") - httpcode, err := database.CreateNewUserFollow(username, newFollow) + err := database.FollowUser(username, newFollow) if err != nil { - RespondWithError(context, httpcode, fmt.Sprintf("Failed to add follower: %v", err)) + RespondWithError(context, http.StatusInternalServerError, fmt.Sprintf("Failed to add follower: %v", err)) return } + + actor, _ := database.GetUserByUsername(username) + user, _ := database.GetUserByUsername(newFollow) + createAndPushNotification( + int64(user.Id), + int64(actor.Id), + "follow_user", + nil, + nil, + nil, + notificationBody(username, "followed you"), + ) context.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("%v now follows %v", username, newFollow)}) } @@ -264,9 +835,9 @@ func UnfollowUser(context *gin.Context) { username := context.Param("username") unFollow := context.Param("unfollow") - httpcode, err := database.RemoveUserFollow(username, unFollow) + err := database.UnfollowUser(username, unFollow) if err != nil { - RespondWithError(context, httpcode, fmt.Sprintf("Failed to remove follower: %v", err)) + RespondWithError(context, http.StatusInternalServerError, fmt.Sprintf("Failed to remove follower: %v", err)) return } context.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("%v unfollowed %v", username, unFollow)}) diff --git a/backend/api/internal/handlers/utils.go b/backend/api/internal/handlers/utils.go index 6931cbc..6597fcd 100644 --- a/backend/api/internal/handlers/utils.go +++ b/backend/api/internal/handlers/utils.go @@ -13,7 +13,6 @@ import ( "strings" "backend/api/internal/logger" - "backend/api/internal/types" "github.com/gin-gonic/gin" ) @@ -38,7 +37,7 @@ func IsFieldAllowed(existingData interface{}, fieldName string) bool { jsonTag := field.Tag.Get("json") // If the JSON tag matches the fieldName, return true - if strings.ToLower(jsonTag) == strings.ToLower(fieldName) { + if strings.EqualFold(jsonTag, fieldName) { return true } } @@ -48,9 +47,21 @@ func IsFieldAllowed(existingData interface{}, fieldName string) bool { func RespondWithError(context *gin.Context, status int, message string) { logger.Log.Infof("Error: %s", message) - response := types.ErrorResponse{ + response := struct { + Error string `json:"error"` + Message string `json:"message"` + }{ Error: http.StatusText(status), Message: message, } context.JSON(status, response) } + +func GetAuthUserID(context *gin.Context) (int64, bool) { + value, ok := context.Get(authUserIDKey) + if !ok || value == nil { + return 0, false + } + userID, ok := value.(int64) + return userID, ok +} diff --git a/backend/api/internal/spec/user-api.yml b/backend/api/internal/spec/user-api.yml index 1f8a75c..f4ffbe0 100644 --- a/backend/api/internal/spec/user-api.yml +++ b/backend/api/internal/spec/user-api.yml @@ -60,8 +60,33 @@ paths: description: Internal server error /users: + get: + summary: List users + responses: + '200': + description: Users retrieved successfully + '500': + description: Internal server error post: summary: Create new user + /users/id/{user_id}: + get: + summary: Get user by ID + parameters: + - name: user_id + in: path + required: true + schema: + type: integer + responses: + '200': + description: User retrieved successfully + '400': + description: Invalid user ID + '404': + description: User not found + '500': + description: Internal server error requestBody: required: true content: @@ -193,6 +218,9 @@ components: User: type: object properties: + id: + type: integer + format: int64 username: type: string bio: diff --git a/backend/api/internal/tests/main_test.go b/backend/api/internal/tests/main_test.go index 94a8a6b..7c3d977 100644 --- a/backend/api/internal/tests/main_test.go +++ b/backend/api/internal/tests/main_test.go @@ -8,16 +8,16 @@ package tests import ( "bytes" + "database/sql" "encoding/json" + "fmt" "io" "net/http" + "os" "testing" - "fmt" - "os" - "database/sql" "github.com/stretchr/testify/assert" - _ "github.com/mattn/go-sqlite3" + _ "modernc.org/sqlite" ) type TestCase struct { @@ -129,7 +129,12 @@ func TestAPI(t *testing.T) { "Post Tests": post_tests, } - db, err := sql.Open("sqlite3", "../database/dev.sqlite3") + dbPath := "../database/dev.sqlite3" + if removeErr := os.Remove(dbPath); removeErr != nil && !os.IsNotExist(removeErr) { + panic(fmt.Sprintf("Failed to reset test database file: %v", removeErr)) + } + + db, err := sql.Open("sqlite", dbPath) if err != nil { panic(fmt.Sprintf("Failed to connect to database: %v", err)) } diff --git a/backend/api/internal/types/types.go b/backend/api/internal/types/types.go deleted file mode 100644 index 4eaf55d..0000000 --- a/backend/api/internal/types/types.go +++ /dev/null @@ -1,86 +0,0 @@ -// the types package is used for creating types that will be -// used multiple times across many packages, so we can make -// use of all of the types in a single package -// -// Some of the structs that will be used to interface between -// frontend, api, and db. This will allow for good handling of -// types -package types - -import ( - "database/sql" - "time" - "encoding/json" -) - -type User struct { - Username string `json:"username" binding:"required"` - Bio string `json:"bio"` - Links []string `json:"links"` - CreationDate time.Time `json:"created_on"` - Picture string `json:"picture"` -} - -type Project struct { - ID int64 `json:"id"` - Owner int64 `json:"owner" binding:"required"` - Name string `json:"name" binding:"required"` - Description string `json:"description" binding:"required"` - Status int16 `json:"status"` - Likes int64 `json:"likes"` - Tags []string `json:"tags"` - Links []string `json:"links"` - CreationDate time.Time `json:"creation_date"` -} - -type Post struct { - ID int64 `json:"id"` - User int64 `json:"user" binding:"required"` - Project int64 `json:"project" binding:"required"` - Likes int64 `json:"likes"` - Content string `json:"content" binding:"required"` - CreationDate time.Time `json:"created_on"` -} - -type Comment struct { - ID int64 `json:"id"` - User int64 `json:"user" binding:"required"` - Likes int64 `json:"likes"` - ParentComment NullableInt64 `json:"parent_comment" binding:"required"` - CreationDate time.Time `json:"created_on"` - Content string `json:"content" binding:"required"` -} - -type ErrorResponse struct { - Error string `json:"error"` - Message string `json:"message"` -} - -// we can implement this type... -type NullableInt64 struct { - sql.NullInt64 -} - -// ...so that we can create custom functions on it -func (n *NullableInt64) UnmarshalJSON(data []byte) error { - if string(data) == "null" { - n.Valid = false - return nil - } - - var number int64 - if err := json.Unmarshal(data, &number); err != nil { - return err - } - - n.Int64 = number - n.Valid = true - return nil -} - -func (n NullableInt64) MarshalJSON() ([]byte, error) { - if !n.Valid { - return []byte("null"), nil - } - return json.Marshal(n.Int64) -} diff --git a/backend/api/main.go b/backend/api/main.go index 045f537..bf63d63 100644 --- a/backend/api/main.go +++ b/backend/api/main.go @@ -3,6 +3,7 @@ package main import ( "log" "os" + "strings" "backend/api/internal/database" "backend/api/internal/handlers" @@ -10,113 +11,211 @@ import ( "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" - _ "github.com/mattn/go-sqlite3" + _ "github.com/lib/pq" // PostgreSQL driver + _ "modernc.org/sqlite" ) -const DEBUG bool = true +const debugEnvKey = "DEVBITS_DEBUG" +const corsOriginsEnvKey = "DEVBITS_CORS_ORIGINS" + +func isDebugMode() bool { + return os.Getenv(debugEnvKey) == "1" +} + +func getAllowedOrigins() []string { + originsCsv := strings.TrimSpace(os.Getenv(corsOriginsEnvKey)) + if originsCsv != "" { + parts := strings.Split(originsCsv, ",") + origins := make([]string, 0, len(parts)) + for _, part := range parts { + origin := strings.TrimSpace(part) + if origin != "" { + origins = append(origins, origin) + } + } + if len(origins) > 0 { + return origins + } + } + + return []string{ + "https://devbits.ddns.net", + "http://localhost:8081", + "http://localhost:19006", + "http://127.0.0.1:8081", + "http://127.0.0.1:19006", + } +} func HealthCheck(context *gin.Context) { context.JSON(200, gin.H{"message": "API is running!"}) } func main() { - if DEBUG { + if isDebugMode() { gin.SetMode(gin.DebugMode) + } else { + gin.SetMode(gin.ReleaseMode) } log.SetOutput(os.Stdout) logger.InitLogger() - router := gin.Default() + // Initialize the database connection + database.Connect() + + router := gin.New() + router.MaxMultipartMemory = 64 << 20 + router.Use(gin.Logger(), gin.Recovery()) + router.Use(func(context *gin.Context) { + path := context.Request.URL.Path + if strings.HasPrefix(path, "/uploads/") { + // Uploaded media is content-addressed (random hex filenames) so + // it is safe to aggressively cache on the client. + context.Header("Cache-Control", "public, max-age=31536000, immutable") + context.Next() + return + } + if path == "/account-deletion" || path == "/privacy-policy" { + context.Next() + return + } + + context.Header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") + context.Header("Pragma", "no-cache") + context.Header("Expires", "0") + context.Next() + }) router.HandleMethodNotAllowed = true + if err := router.SetTrustedProxies([]string{"127.0.0.1", "::1"}); err != nil { + log.Printf("WARN: could not set trusted proxies: %v", err) + } // Apply CORS middleware to the router - router.Use(cors.New(cors.Config{ - AllowOrigins: []string{"http://localhost:8081"}, // Add your frontend URL (React Native or Web app) + corsConfig := cors.Config{ AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, AllowHeaders: []string{"Origin", "Content-Type", "Authorization"}, - AllowCredentials: true, // Allow cookies or authentication headers - })) + AllowCredentials: true, + } + if isDebugMode() { + corsConfig.AllowOriginFunc = func(origin string) bool { return true } + } else { + corsConfig.AllowOrigins = getAllowedOrigins() + } + router.Use(cors.New(corsConfig)) + + router.Static("/uploads", "./uploads") + + router.GET("/", func(c *gin.Context) { + c.String(200, "Welcome to the DevBits API! Everything is running correctly.") + }) router.GET("/health", HealthCheck) + router.POST("/auth/register", handlers.Register) + router.POST("/auth/login", handlers.Login) + router.GET("/auth/me", handlers.RequireAuth(), handlers.GetMe) + + router.POST("/media/upload", handlers.RequireAuth(), handlers.UploadMedia) + + router.GET("/users", handlers.GetUsers) router.GET("/users/:username", handlers.GetUserByUsername) - router.POST("/users", handlers.CreateUser) - router.PUT("/users/:username", handlers.UpdateUserInfo) - router.DELETE("/users/:username", handlers.DeleteUser) + router.GET("/users/id/:user_id", handlers.GetUserById) + router.POST("/users", handlers.RequireAuth(), handlers.CreateUser) + router.PUT("/users/:username", handlers.RequireAuth(), handlers.RequireSameUser(), handlers.UpdateUserInfo) + router.POST("/users/:username/update", handlers.RequireAuth(), handlers.RequireSameUser(), handlers.UpdateUserInfo) + router.PUT("/users/:username/profile-picture", handlers.RequireAuth(), handlers.RequireSameUser(), handlers.UpdateProfilePicture) + router.GET("/users/:username/media", handlers.RequireAuth(), handlers.RequireSameUser(), handlers.GetUserManagedMedia) + router.DELETE("/users/:username/media", handlers.RequireAuth(), handlers.RequireSameUser(), handlers.DeleteUserManagedMedia) + router.DELETE("/users/:username", handlers.RequireAuth(), handlers.RequireSameUser(), handlers.DeleteUser) router.GET("/users/:username/followers", handlers.GetUsersFollowers) router.GET("/users/:username/follows", handlers.GetUsersFollowing) router.GET("/users/:username/followers/usernames", handlers.GetUsersFollowersUsernames) router.GET("/users/:username/follows/usernames", handlers.GetUsersFollowingUsernames) - router.POST("/users/:username/follow/:new_follow", handlers.FollowUser) - router.POST("/users/:username/unfollow/:unfollow", handlers.UnfollowUser) + router.POST("/users/:username/follow/:new_follow", handlers.RequireAuth(), handlers.RequireSameUser(), handlers.FollowUser) + router.POST("/users/:username/unfollow/:unfollow", handlers.RequireAuth(), handlers.RequireSameUser(), handlers.UnfollowUser) + + router.GET("/messages/:username/peers", handlers.RequireAuth(), handlers.RequireSameUser(), handlers.GetDirectChatPeers) + router.GET("/messages/:username/threads", handlers.RequireAuth(), handlers.RequireSameUser(), handlers.GetDirectMessageThreads) + router.GET("/messages/:username/with/:other", handlers.RequireAuth(), handlers.RequireSameUser(), handlers.GetDirectMessages) + router.POST("/messages/:username/with/:other", handlers.RequireAuth(), handlers.RequireSameUser(), handlers.CreateDirectMessage) + router.GET("/messages/:username/stream", handlers.StreamDirectMessages) router.GET("/projects/:project_id", handlers.GetProjectById) - router.POST("/projects", handlers.CreateProject) - router.PUT("/projects/:project_id", handlers.UpdateProjectInfo) - router.DELETE("/projects/:project_id", handlers.DeleteProject) + router.POST("/projects", handlers.RequireAuth(), handlers.CreateProject) + router.PUT("/projects/:project_id", handlers.RequireAuth(), handlers.UpdateProjectInfo) + router.DELETE("/projects/:project_id", handlers.RequireAuth(), handlers.DeleteProject) router.GET("/projects/by-user/:user_id", handlers.GetProjectsByUserId) + router.GET("/projects/by-builder/:user_id", handlers.RequireAuth(), handlers.GetProjectsByBuilderId) + + router.GET("/projects/:project_id/builders", handlers.GetProjectBuilders) + router.POST("/projects/:project_id/builders/:username", handlers.RequireAuth(), handlers.AddProjectBuilder) + router.DELETE("/projects/:project_id/builders/:username", handlers.RequireAuth(), handlers.RemoveProjectBuilder) router.GET("/projects/:project_id/followers", handlers.GetProjectFollowers) router.GET("/projects/follows/:username", handlers.GetProjectFollowing) router.GET("/projects/:project_id/followers/usernames", handlers.GetProjectFollowersUsernames) router.GET("/projects/follows/:username/names", handlers.GetProjectFollowingNames) - router.POST("/projects/:username/follow/:project_id", handlers.FollowProject) - router.POST("/projects/:username/unfollow/:project_id", handlers.UnfollowProject) + router.POST("/projects/user/:username/follow/:project_id", handlers.RequireAuth(), handlers.RequireSameUser(), handlers.FollowProject) + router.POST("/projects/user/:username/unfollow/:project_id", handlers.RequireAuth(), handlers.RequireSameUser(), handlers.UnfollowProject) - router.POST("/projects/:username/likes/:project_id", handlers.LikeProject) - router.POST("/projects/:username/unlikes/:project_id", handlers.UnlikeProject) + router.POST("/projects/user/:username/likes/:project_id", handlers.RequireAuth(), handlers.RequireSameUser(), handlers.LikeProject) + router.POST("/projects/user/:username/unlikes/:project_id", handlers.RequireAuth(), handlers.RequireSameUser(), handlers.UnlikeProject) router.GET("/projects/does-like/:username/:project_id", handlers.IsProjectLiked) router.GET("/posts/:post_id", handlers.GetPostById) - router.POST("/posts", handlers.CreatePost) - router.PUT("/posts/:post_id", handlers.UpdatePostInfo) - router.DELETE("/posts/:post_id", handlers.DeletePost) + router.POST("/posts", handlers.RequireAuth(), handlers.CreatePost) + router.PUT("/posts/:post_id", handlers.RequireAuth(), handlers.UpdatePostInfo) + router.DELETE("/posts/:post_id", handlers.RequireAuth(), handlers.DeletePost) router.GET("/posts/by-user/:user_id", handlers.GetPostsByUserId) router.GET("/posts/by-project/:project_id", handlers.GetPostsByProjectId) - router.POST("/posts/:username/likes/:post_id", handlers.LikePost) - router.POST("/posts/:username/unlikes/:post_id", handlers.UnlikePost) + router.POST("/posts/:username/likes/:post_id", handlers.RequireAuth(), handlers.RequireSameUser(), handlers.LikePost) + router.POST("/posts/:username/unlikes/:post_id", handlers.RequireAuth(), handlers.RequireSameUser(), handlers.UnlikePost) router.GET("/posts/does-like/:username/:post_id", handlers.IsPostLiked) + router.POST("/posts/:username/save/:post_id", handlers.RequireAuth(), handlers.RequireSameUser(), handlers.SavePost) + router.POST("/posts/:username/unsave/:post_id", handlers.RequireAuth(), handlers.RequireSameUser(), handlers.UnsavePost) + router.GET("/posts/saved/:username", handlers.RequireAuth(), handlers.RequireSameUser(), handlers.GetSavedPosts) - router.POST("/comments/for-post/:post_id", handlers.CreateCommentOnPost) - router.POST("/comments/for-project/:project_id", handlers.CreateCommentOnProject) - router.POST("/comments/for-comment/:comment_id", handlers.CreateCommentOnComment) + router.POST("/comments/for-post/:post_id", handlers.RequireAuth(), handlers.CreateCommentOnPost) + router.POST("/comments/for-project/:project_id", handlers.RequireAuth(), handlers.CreateCommentOnProject) + router.POST("/comments/for-comment/:comment_id", handlers.RequireAuth(), handlers.CreateCommentOnComment) router.GET("/comments/:comment_id", handlers.GetCommentById) - router.PUT("/comments/:comment_id", handlers.UpdateCommentContent) - router.DELETE("/comments/:comment_id", handlers.DeleteComment) + router.PUT("/comments/:comment_id", handlers.RequireAuth(), handlers.UpdateCommentContent) + router.DELETE("/comments/:comment_id", handlers.RequireAuth(), handlers.DeleteComment) router.GET("/comments/by-user/:user_id", handlers.GetCommentsByUserId) router.GET("/comments/by-post/:post_id", handlers.GetCommentsByPostId) router.GET("/comments/by-project/:project_id", handlers.GetCommentsByProjectId) router.GET("/comments/by-comment/:comment_id", handlers.GetCommentsByCommentId) - router.POST("/comments/:username/likes/:comment_id", handlers.LikeComment) - router.POST("/comments/:username/unlikes/:comment_id", handlers.UnlikeComment) + router.POST("/comments/:username/likes/:comment_id", handlers.RequireAuth(), handlers.RequireSameUser(), handlers.LikeComment) + router.POST("/comments/:username/unlikes/:comment_id", handlers.RequireAuth(), handlers.RequireSameUser(), handlers.UnlikeComment) router.GET("/comments/does-like/:username/:comment_id", handlers.IsCommentLiked) router.GET("/comments/can-edit/:comment_id", handlers.IsCommentEditable) router.GET("/feed/posts", handlers.GetPostsFeed) router.GET("/feed/projects", handlers.GetProjectsFeed) - - var dbinfo, dbtype string - if DEBUG { - dbinfo = "./api/internal/database/dev.sqlite3" - dbtype = "sqlite3" - } else { - dbinfo = os.Getenv("DB_INFO") - dbtype = os.Getenv("DB_TYPE") - if dbinfo == "" { - log.Fatalln("FATAL: debug mode is false and 'DB_INFO' doesn't exist!") - } - if dbtype == "" { - log.Fatalln("FATAL: debug mode is false and 'DB_TYPE' doesn't exist!") + router.GET("/feed/posts/following/:username", handlers.RequireAuth(), handlers.RequireSameUser(), handlers.GetFollowingPostsFeed) + router.GET("/feed/posts/saved/:username", handlers.RequireAuth(), handlers.RequireSameUser(), handlers.GetSavedPostsFeed) + router.GET("/feed/projects/following/:username", handlers.RequireAuth(), handlers.RequireSameUser(), handlers.GetFollowingProjectsFeed) + router.GET("/feed/projects/saved/:username", handlers.RequireAuth(), handlers.RequireSameUser(), handlers.GetSavedProjectsFeed) + + router.POST("/notifications/push-token", handlers.RequireAuth(), handlers.RegisterPushToken) + router.GET("/notifications", handlers.RequireAuth(), handlers.GetNotifications) + router.GET("/notifications/unread-count", handlers.RequireAuth(), handlers.GetNotificationCount) + router.POST("/notifications/:notification_id/read", handlers.RequireAuth(), handlers.MarkNotificationRead) + router.DELETE("/notifications/:notification_id", handlers.RequireAuth(), handlers.DeleteNotification) + router.DELETE("/notifications", handlers.RequireAuth(), handlers.ClearNotifications) + + if err := router.Run("0.0.0.0:8080"); err != nil { + log.Printf("ERROR: failed to start API server on 0.0.0.0:8080: %v", err) + if isDebugMode() { + log.Println("HINT: port 8080 is likely already in use. Stop the existing backend process or run only one launcher.") } + os.Exit(1) } - database.Connect(dbinfo, dbtype) - - router.Run("localhost:8080") } diff --git a/backend/backups/db/devbits-db-20260222-030002.sql b/backend/backups/db/devbits-db-20260222-030002.sql new file mode 100644 index 0000000..e69de29 diff --git a/backend/backups/db/devbits-uploads-20260222-030002.zip b/backend/backups/db/devbits-uploads-20260222-030002.zip new file mode 100644 index 0000000..991995f Binary files /dev/null and b/backend/backups/db/devbits-uploads-20260222-030002.zip differ diff --git a/backend/crash-errors.log b/backend/crash-errors.log new file mode 100644 index 0000000..e69de29 diff --git a/backend/crash.log b/backend/crash.log new file mode 100644 index 0000000..e69de29 diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml new file mode 100644 index 0000000..bd7a876 --- /dev/null +++ b/backend/docker-compose.yml @@ -0,0 +1,68 @@ +services: + db: + image: postgres:15 + container_name: devbits-postgres + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-devbits} + POSTGRES_USER: ${POSTGRES_USER:-devbits} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required (set it in backend/.env)} + volumes: + - postgres-data:/var/lib/postgresql/data + security_opt: + - no-new-privileges:true + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 20 + networks: + - app-network + + backend: + build: + context: . + dockerfile: Dockerfile + container_name: devbits-backend + restart: unless-stopped + volumes: + # Mount the uploads directory to persist data + - ./uploads:/root/uploads + security_opt: + - no-new-privileges:true + networks: + - app-network + depends_on: + db: + condition: service_healthy + environment: + - DATABASE_URL=postgres://${POSTGRES_USER:-devbits}:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}@db:5432/${POSTGRES_DB:-devbits}?sslmode=disable + + nginx: + image: nginx:latest + container_name: devbits-nginx + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + # Mount volumes for Let's Encrypt certificates + - ./nginx/certs:/etc/letsencrypt + # Serve static site assets (public pages + screenshots) + - ../frontend/public:/usr/share/nginx/html:ro + depends_on: + backend: + condition: service_started + security_opt: + - no-new-privileges:true + networks: + - app-network + +networks: + app-network: + driver: bridge + +volumes: + postgres-data: + driver: local diff --git a/backend/go.mod b/backend/go.mod index bd0b1c2..57a50c3 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,14 +1,16 @@ module backend -go 1.23.2 +go 1.24.0 require ( github.com/gin-contrib/cors v1.7.2 github.com/gin-gonic/gin v1.10.0 - github.com/mattn/go-sqlite3 v1.14.24 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/gorilla/websocket v1.5.3 github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.9.0 - + golang.org/x/crypto v0.31.0 + modernc.org/sqlite v1.44.3 ) require ( @@ -17,28 +19,36 @@ require ( github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/kr/text v0.2.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/lib/pq v1.11.2 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect golang.org/x/arch v0.8.0 // indirect - golang.org/x/crypto v0.31.0 // indirect + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/net v0.33.0 // indirect - golang.org/x/sys v0.28.0 // indirect + golang.org/x/sys v0.37.0 // indirect golang.org/x/text v0.21.0 // indirect google.golang.org/protobuf v1.34.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.67.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 19fd877..2a213a1 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -10,6 +10,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw= @@ -28,9 +30,19 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= @@ -43,19 +55,23 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs= +github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= -github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= @@ -81,17 +97,23 @@ golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -100,5 +122,33 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= +modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= +modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= +modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY= +modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/backend/nginx/nginx.conf b/backend/nginx/nginx.conf new file mode 100644 index 0000000..fe76106 --- /dev/null +++ b/backend/nginx/nginx.conf @@ -0,0 +1,420 @@ +worker_processes 1; + +events { + worker_connections 1024; +} + +http { + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + map "$request_method $request_uri" $loggable { + default 1; + "GET /favicon.ico" 0; + "GET /apple-touch-icon.png" 0; + "GET /apple-touch-icon-precomposed.png" 0; + "POST /" 0; + } + + map $request_uri $honeypot_hit { + default 0; + ~*^/(wp-admin|wp-login\.php|xmlrpc\.php|phpmyadmin|pma|\.git|\.env|cgi-bin|boaform|actuator|manager/html|server-status|vendor/phpunit|\.well-known/pki-validation) 1; + } + + map $honeypot_hit $honeypot_loggable { + default 0; + 1 1; + } + + access_log /dev/stdout combined if=$loggable; + access_log /dev/stdout combined if=$honeypot_loggable; + error_log /dev/stderr warn; + + limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=10r/m; + limit_req_zone $binary_remote_addr zone=api_limit:10m rate=30r/s; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_comp_level 6; + gzip_types + text/plain + text/css + text/xml + application/xml + application/json + application/javascript + image/svg+xml; + + include /etc/nginx/mime.types; + default_type application/octet-stream; + client_max_body_size 64m; + server_tokens off; + + # HTTP server for local development and health checks + server { + listen 80 default_server; + server_name _; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "same-origin" always; + + location ~* ^/(wp-admin|wp-login\.php|xmlrpc\.php|phpmyadmin|pma|\.git|\.env|cgi-bin|boaform|actuator|manager/html|server-status|vendor/phpunit|\.well-known/pki-validation) { + return 444; + } + + location ~* ^/(media/upload|users/.+/(profile-picture|update))$ { + client_max_body_size 64m; + client_body_timeout 180s; + proxy_pass http://backend:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache off; + proxy_cache_bypass 1; + proxy_no_cache 1; + proxy_request_buffering on; + proxy_buffering on; + proxy_read_timeout 180s; + proxy_send_timeout 180s; + add_header Cache-Control "no-store" always; + } + + location ~* ^/users/[^/]+$ { + proxy_pass http://backend:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache off; + proxy_cache_bypass 1; + proxy_no_cache 1; + proxy_request_buffering on; + proxy_buffering on; + proxy_read_timeout 180s; + proxy_send_timeout 180s; + add_header Cache-Control "no-store" always; + } + + location ^~ /uploads/ { + proxy_pass http://backend:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + add_header Cache-Control "no-cache, max-age=0, must-revalidate" always; + } + + location ~* \.(png|jpg|jpeg|gif|webp|svg|ico)$ { + root /usr/share/nginx/html; + try_files $uri =404; + add_header Cache-Control "public, max-age=86400" always; + } + + location = /Cards.svg { + default_type image/svg+xml; + alias /usr/share/nginx/html/Cards.svg; + add_header Cache-Control "public, max-age=86400" always; + } + + location = /cards.svg { + default_type image/svg+xml; + alias /usr/share/nginx/html/Cards.svg; + add_header Cache-Control "public, max-age=86400" always; + } + + location / { + root /usr/share/nginx/html; + try_files $uri $uri/ =404; + } + + location /api/ { + limit_req zone=api_limit burst=120 nodelay; + proxy_pass http://backend:8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache off; + proxy_cache_bypass 1; + proxy_no_cache 1; + add_header Cache-Control "no-store" always; + } + + location = /api/auth/login { + limit_req zone=auth_limit burst=20 nodelay; + proxy_pass http://backend:8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache off; + proxy_cache_bypass 1; + proxy_no_cache 1; + add_header Cache-Control "no-store" always; + } + + location = /api/auth/register { + limit_req zone=auth_limit burst=20 nodelay; + proxy_pass http://backend:8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache off; + proxy_cache_bypass 1; + proxy_no_cache 1; + add_header Cache-Control "no-store" always; + } + } + + # Redirect all HTTP traffic to HTTPS for production domain + server { + listen 80; + server_name devbits.ddns.net; # <-- IMPORTANT: Replace with your DDNS domain + return 301 https://$host$request_uri; + } + + # HTTPS server + server { + listen 443 ssl; + server_name devbits.ddns.net; # <-- IMPORTANT: Replace with your DDNS domain + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "same-origin" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + + location ~* ^/(wp-admin|wp-login\.php|xmlrpc\.php|phpmyadmin|pma|\.git|\.env|cgi-bin|boaform|actuator|manager/html|server-status|vendor/phpunit|\.well-known/pki-validation) { + return 444; + } + + # SSL Certificate + ssl_certificate /etc/letsencrypt/live/devbits.ddns.net/fullchain.pem; # <-- IMPORTANT: Replace with your domain + ssl_certificate_key /etc/letsencrypt/live/devbits.ddns.net/privkey.pem; # <-- IMPORTANT: Replace with your domain + + # Improve SSL security + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers 'TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384'; + ssl_prefer_server_ciphers off; + + location ~* ^/(media/upload|users/.+/(profile-picture|update))$ { + client_max_body_size 64m; + client_body_timeout 180s; + proxy_pass http://backend:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache off; + proxy_cache_bypass 1; + proxy_no_cache 1; + proxy_request_buffering on; + proxy_buffering on; + proxy_read_timeout 180s; + proxy_send_timeout 180s; + add_header Cache-Control "no-store" always; + } + + location ~* ^/users/[^/]+$ { + proxy_pass http://backend:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache off; + proxy_cache_bypass 1; + proxy_no_cache 1; + proxy_request_buffering on; + proxy_buffering on; + proxy_read_timeout 180s; + proxy_send_timeout 180s; + add_header Cache-Control "no-store" always; + } + + location ^~ /uploads/ { + proxy_pass http://backend:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + add_header Cache-Control "no-cache, max-age=0, must-revalidate" always; + } + + location ~* \.(png|jpg|jpeg|gif|webp|svg|ico)$ { + root /usr/share/nginx/html; + try_files $uri =404; + add_header Cache-Control "public, max-age=86400" always; + } + + location = /Cards.svg { + default_type image/svg+xml; + alias /usr/share/nginx/html/Cards.svg; + add_header Cache-Control "public, max-age=86400" always; + } + + location = /cards.svg { + default_type image/svg+xml; + alias /usr/share/nginx/html/Cards.svg; + add_header Cache-Control "public, max-age=86400" always; + } + + location / { + root /usr/share/nginx/html; + try_files $uri $uri/ @backend; + } + + location @backend { + proxy_pass http://backend:8080; # "backend" is the service name in docker-compose + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache off; + proxy_cache_bypass 1; + proxy_no_cache 1; + add_header Cache-Control "no-store" always; + } + + # Serve static pages directly from nginx + location = /account-deletion { + default_type text/html; + alias /usr/share/nginx/html/account-deletion.html; + } + + location = /account-deletion.html { + default_type text/html; + alias /usr/share/nginx/html/account-deletion.html; + } + + # Serve the Privacy Policy page required by store listings + location = /privacy-policy { + default_type text/html; + alias /usr/share/nginx/html/privacy-policy.html; + } + + location = /privacy-policy.html { + default_type text/html; + alias /usr/share/nginx/html/privacy-policy.html; + } + + location = /api/auth/login { + limit_req zone=auth_limit burst=20 nodelay; + proxy_pass http://backend:8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache off; + proxy_cache_bypass 1; + proxy_no_cache 1; + add_header Cache-Control "no-store" always; + } + + location = /api/auth/register { + limit_req zone=auth_limit burst=20 nodelay; + proxy_pass http://backend:8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache off; + proxy_cache_bypass 1; + proxy_no_cache 1; + add_header Cache-Control "no-store" always; + } + + location /api/ { + limit_req zone=api_limit burst=120 nodelay; + proxy_pass http://backend:8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache off; + proxy_cache_bypass 1; + proxy_no_cache 1; + add_header Cache-Control "no-store" always; + } + + # Serve the CSAE (Child Sexual Abuse & Exploitation) standards page + location = /csae-standards { + default_type text/html; + alias /usr/share/nginx/html/csae-standards.html; + } + + location = /csae-standards.html { + default_type text/html; + alias /usr/share/nginx/html/csae-standards.html; + } + + # Serve the tester application page + location = /tester-application { + default_type text/html; + alias /usr/share/nginx/html/tester-application.html; + } + + location = /tester-application.html { + default_type text/html; + alias /usr/share/nginx/html/tester-application.html; + } + + # Serve features page + location = /features { + default_type text/html; + alias /usr/share/nginx/html/features.html; + } + + location = /features.html { + default_type text/html; + alias /usr/share/nginx/html/features.html; + } + + # Serve about page + location = /about { + default_type text/html; + alias /usr/share/nginx/html/about.html; + } + + location = /about.html { + default_type text/html; + alias /usr/share/nginx/html/about.html; + } + + } +} diff --git a/backend/scripts/README.md b/backend/scripts/README.md new file mode 100644 index 0000000..829b448 --- /dev/null +++ b/backend/scripts/README.md @@ -0,0 +1,165 @@ +# DevBits Database Scripts + +All deployment database scripts are in this folder. + +## Environment separation (important) + +### Local DB (development machine) + +Run from project root: + +```powershell +cd c:\Users\eligf\DevBits +``` + +Use compose file path explicitly: + +```powershell +docker compose -f backend/docker-compose.yml up -d +docker compose -f backend/docker-compose.yml logs -f db +``` + +### Live DB (deployed server) + +Run on server in backend directory: + +```bash +cd /path/to/DevBits/backend +docker compose up -d +docker compose logs -f db +``` + +Only run reset/restore in the environment you mean to modify. + +## Script location + +Run script commands from `backend`: + +```powershell +cd backend +``` + +## Required env file + +Before running deploy/reset/update scripts, ensure `backend/.env` exists: + +```powershell +Copy-Item .env.example .env +``` + +Set a strong `POSTGRES_PASSWORD` value in `.env`. + +## Scripts + +- `scripts/reset-deployment-db.ps1` / `scripts/reset-deployment-db.sh` +- `scripts/backup-deployment-db.ps1` / `scripts/backup-deployment-db.sh` +- `scripts/restore-deployment-db.ps1` / `scripts/restore-deployment-db.sh` +- `scripts/setup-daily-backup-task.ps1` +- `scripts/disable-daily-backup-task.ps1` + +## 1) Reset DB (blank slate) + +Warning: this wipes all app data in that environment. + +PowerShell: + +```powershell +./scripts/reset-deployment-db.ps1 +``` + +Keep uploads while resetting only DB volume: + +```powershell +./scripts/reset-deployment-db.ps1 -KeepUploads +``` + +Bash: + +```bash +./scripts/reset-deployment-db.sh +./scripts/reset-deployment-db.sh --keep-uploads +``` + +## 2) Backup DB (single-backup retention) + +Safe for both local and live. Run it in the target environment. + +PowerShell: + +```powershell +./scripts/backup-deployment-db.ps1 +``` + +Bash: + +```bash +./scripts/backup-deployment-db.sh +``` + +Backup location: + +- `backend/backups/db` + +Retention policy: + +- keeps only the newest `devbits-*.sql` +- deletes older backup files automatically + +Backup type: + +- Logical SQL dump created with `pg_dump` from the running DB container +- Not a Docker volume snapshot/image snapshot + +## 3) Restore DB from latest backup + +Warning: restore terminates sessions and recreates DB in that environment. + +PowerShell: + +```powershell +./scripts/restore-deployment-db.ps1 +``` + +Bash: + +```bash +./scripts/restore-deployment-db.sh +``` + +Restore behavior: + +- picks latest backup file from `backend/backups/db` +- terminates active DB sessions +- drops and recreates `devbits` +- applies SQL dump + +## 4) Enable daily auto backup (Windows) + +Create a scheduled task at 03:00 daily: + +```powershell +./scripts/setup-daily-backup-task.ps1 +``` + +Custom time: + +```powershell +./scripts/setup-daily-backup-task.ps1 -RunAt "01:30" +``` + +Notes: + +- Script tries `SYSTEM` first. +- If shell is not elevated, it falls back to current-user mode. + +Verify task: + +```powershell +schtasks /Query /TN DevBitsDailyDbBackup /V /FO LIST +``` + +## 5) Disable daily auto backup (Windows) + +```powershell +./scripts/disable-daily-backup-task.ps1 +``` diff --git a/backend/scripts/backup-deployment-db.ps1 b/backend/scripts/backup-deployment-db.ps1 new file mode 100644 index 0000000..466a097 --- /dev/null +++ b/backend/scripts/backup-deployment-db.ps1 @@ -0,0 +1,89 @@ +param( + [string]$BackupDir = "backups\db", + [switch]$NoPause +) + +$ErrorActionPreference = "Stop" + +if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + $arguments = "& '" + $myinvocation.mycommand.definition + "'" + Start-Process powershell -Verb runAs -ArgumentList $arguments + exit +} + +Write-Host "Creating deployment database backup..." -ForegroundColor Yellow + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$root = Resolve-Path (Join-Path $scriptDir "..") +Push-Location $root +try { + $backupPath = Join-Path $root $BackupDir + New-Item -ItemType Directory -Path $backupPath -Force | Out-Null + + $envFile = Join-Path $root ".env" + $dbUser = "devbits" + $dbName = "devbits" + if (Test-Path $envFile) { + $envRaw = Get-Content -Path $envFile -Raw + if ($envRaw -match "(?m)^POSTGRES_USER=(.+)$") { + $dbUser = $Matches[1].Trim() + } + if ($envRaw -match "(?m)^POSTGRES_DB=(.+)$") { + $dbName = $Matches[1].Trim() + } + } + + $timestamp = Get-Date -Format "yyyyMMdd-HHmmss" + $dbBackupFileName = "devbits-db-$timestamp.sql" + $dbBackupFile = Join-Path $backupPath $dbBackupFileName + + docker compose exec -T db pg_dump -U $dbUser -d $dbName --no-owner --no-privileges | Out-File -FilePath $dbBackupFile -Encoding utf8 + + if (-not (Test-Path $dbBackupFile)) { + throw "Database backup file was not created." + } + + $uploadsDir = Join-Path $root "uploads" + $uploadsBackupFileName = "devbits-uploads-$timestamp.zip" + $uploadsBackupFile = Join-Path $backupPath $uploadsBackupFileName + + if (Test-Path $uploadsDir) { + Compress-Archive -Path "$uploadsDir\*" -DestinationPath $uploadsBackupFile -Force + Write-Host "Uploads backup created: $uploadsBackupFile" -ForegroundColor Green + } + else { + Write-Host "Uploads directory not found, skipping backup." -ForegroundColor Yellow + } + + $filesToKeep = @( + (Get-ChildItem -Path $backupPath -Filter "devbits-db-*.sql" | Sort-Object LastWriteTime -Descending | Select-Object -First 1).FullName + (Get-ChildItem -Path $backupPath -Filter "devbits-uploads-*.zip" | Sort-Object LastWriteTime -Descending | Select-Object -First 1).FullName + ) + + Get-ChildItem -Path $backupPath -File | Where-Object { $_.FullName -notin $filesToKeep } | Remove-Item -Force + + Write-Host "Backup created: $dbBackupFile" -ForegroundColor Green + Write-Host "Retention applied: only latest backup of each type is kept." -ForegroundColor Green +} +finally { + Pop-Location +} + +$liveBackendState = "unavailable" +try { + $statusOutput = docker compose -f (Join-Path $root "docker-compose.yml") ps backend 2>$null + if ($LASTEXITCODE -eq 0) { + $liveBackendState = if (($statusOutput | Out-String) -match "Up") { "running" } else { "not running" } + } +} +catch {} + +Write-Host "" +Write-Host "===== Summary =====" -ForegroundColor Cyan +Write-Host "Action: Deployment backup created" +Write-Host "Updated: Latest DB + uploads backup files retained" +Write-Host "Live backend: $liveBackendState" + +if (-not $NoPause -and [Environment]::UserInteractive -and $Host.Name -eq "ConsoleHost") { + Read-Host "Press Enter to close" +} diff --git a/backend/scripts/backup-deployment-db.sh b/backend/scripts/backup-deployment-db.sh new file mode 100644 index 0000000..2463bed --- /dev/null +++ b/backend/scripts/backup-deployment-db.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$EUID" -ne 0 ]; then + echo "Please run as root" + sudo "$0" "$@" + exit +fi + +BACKUP_DIR="${1:-backups/db}" + +echo "Creating deployment database backup..." + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$ROOT_DIR" + +if [[ -f ".env" ]]; then + set -a + . ./.env + set +a +fi + +DB_USER="${POSTGRES_USER:-devbits}" +DB_NAME="${POSTGRES_DB:-devbits}" + +mkdir -p "$BACKUP_DIR" + +TIMESTAMP="$(date +"%Y%m%d-%H%M%S")" +DB_BACKUP_FILE_NAME="devbits-db-${TIMESTAMP}.sql" +DB_OUTPUT_FILE="${BACKUP_DIR}/${DB_BACKUP_FILE_NAME}" + +docker compose exec -T db pg_dump -U "$DB_USER" -d "$DB_NAME" --no-owner --no-privileges > "$DB_OUTPUT_FILE" + +if [[ ! -s "$DB_OUTPUT_FILE" ]]; then + echo "Database backup file is empty or missing. Aborting." >&2 + exit 1 +fi +echo "Database backup created: $DB_OUTPUT_FILE" + +UPLOADS_DIR="uploads" +UPLOADS_BACKUP_FILE_NAME="devbits-uploads-${TIMESTAMP}.tar.gz" +UPLOADS_OUTPUT_FILE="${BACKUP_DIR}/${UPLOADS_BACKUP_FILE_NAME}" + +if [ -d "$UPLOADS_DIR" ]; then + tar -czf "$UPLOADS_OUTPUT_FILE" -C "$UPLOADS_DIR" . + echo "Uploads backup created: $UPLOADS_OUTPUT_FILE" +else + echo "Uploads directory not found, skipping backup." +fi + +# Retention: keep only the latest db backup and the latest uploads backup +find "$BACKUP_DIR" -maxdepth 1 -type f -name 'devbits-db-*.sql' | sort | head -n -1 | xargs -r rm +find "$BACKUP_DIR" -maxdepth 1 -type f -name 'devbits-uploads-*.tar.gz' | sort | head -n -1 | xargs -r rm + +echo "Retention applied: only latest backup of each type is kept." + +live_backend_state="unavailable" +if backend_status_output="$(docker compose ps backend 2>/dev/null)"; then + if echo "$backend_status_output" | grep -q "Up"; then + live_backend_state="running" + else + live_backend_state="not running" + fi +fi + +echo +echo "===== Summary =====" +echo "Action: Deployment backup created" +echo "Updated: Latest DB + uploads backup files retained" +echo "Live backend: $live_backend_state" diff --git a/backend/scripts/disable-daily-backup-task.ps1 b/backend/scripts/disable-daily-backup-task.ps1 new file mode 100644 index 0000000..804b456 --- /dev/null +++ b/backend/scripts/disable-daily-backup-task.ps1 @@ -0,0 +1,43 @@ +param( + [string]$TaskName = "DevBitsDailyDbBackup", + [switch]$NoPause +) + +$ErrorActionPreference = "Stop" + +if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + $arguments = "& '" + $myinvocation.mycommand.definition + "'" + Start-Process powershell -Verb runAs -ArgumentList $arguments + exit +} + +Write-Host "Removing scheduled task '$TaskName'..." -ForegroundColor Yellow + +schtasks /Delete /F /TN $TaskName | Out-Null + +if ($LASTEXITCODE -ne 0) { + throw "Failed to remove scheduled task '$TaskName'." +} + +Write-Host "Task removed." -ForegroundColor Green + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$root = Resolve-Path (Join-Path $scriptDir "..") +$liveBackendState = "unavailable" +try { + $statusOutput = docker compose -f (Join-Path $root "docker-compose.yml") ps backend 2>$null + if ($LASTEXITCODE -eq 0) { + $liveBackendState = if (($statusOutput | Out-String) -match "Up") { "running" } else { "not running" } + } +} +catch {} + +Write-Host "" +Write-Host "===== Summary =====" -ForegroundColor Cyan +Write-Host "Action: Daily backup task removed" +Write-Host "Updated: Windows scheduled task '$TaskName' deleted" +Write-Host "Live backend: $liveBackendState" + +if (-not $NoPause -and [Environment]::UserInteractive -and $Host.Name -eq "ConsoleHost") { + Read-Host "Press Enter to close" +} diff --git a/backend/scripts/reset-deployment-db.ps1 b/backend/scripts/reset-deployment-db.ps1 new file mode 100644 index 0000000..ee7acae --- /dev/null +++ b/backend/scripts/reset-deployment-db.ps1 @@ -0,0 +1,59 @@ +param( + [switch]$KeepUploads, + [switch]$NoPause +) + +$ErrorActionPreference = "Stop" + +Write-Host "Resetting DevBits deployment database to a blank slate..." -ForegroundColor Yellow + +if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + $arguments = "& '" + $myinvocation.mycommand.definition + "'" + Start-Process powershell -Verb runAs -ArgumentList $arguments + exit +} + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$root = Resolve-Path (Join-Path $scriptDir "..") +Push-Location $root +try { + $envFile = Join-Path $root ".env" + if (-not (Test-Path $envFile)) { + throw "Missing $envFile. Create it from backend/.env.example and set strong credentials before resetting." + } + + docker compose down -v --remove-orphans + + if (-not $KeepUploads) { + $uploadsPath = Join-Path $root "uploads" + if (Test-Path $uploadsPath) { + Get-ChildItem -Path $uploadsPath -Force -ErrorAction SilentlyContinue | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue + } + } + + docker compose up -d --build + + Write-Host "Database reset complete. All users and app data are removed." -ForegroundColor Green +} +finally { + Pop-Location +} + +$liveBackendState = "unavailable" +try { + $statusOutput = docker compose -f (Join-Path $root "docker-compose.yml") ps backend 2>$null + if ($LASTEXITCODE -eq 0) { + $liveBackendState = if (($statusOutput | Out-String) -match "Up") { "running" } else { "not running" } + } +} +catch {} + +Write-Host "" +Write-Host "===== Summary =====" -ForegroundColor Cyan +Write-Host "Action: Deployment DB reset executed" +Write-Host "Updated: Database recreated and services rebuilt" +Write-Host "Live backend: $liveBackendState" + +if (-not $NoPause -and [Environment]::UserInteractive -and $Host.Name -eq "ConsoleHost") { + Read-Host "Press Enter to close" +} diff --git a/backend/scripts/reset-deployment-db.sh b/backend/scripts/reset-deployment-db.sh new file mode 100644 index 0000000..868dbfe --- /dev/null +++ b/backend/scripts/reset-deployment-db.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +KEEP_UPLOADS="${1:-}" + +if [ "$EUID" -ne 0 ]; then + echo "Please run as root" + sudo "$0" "$@" + exit +fi + +echo "Resetting DevBits deployment database to a blank slate..." + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$ROOT_DIR" + +if [[ ! -f ".env" ]]; then + echo "Missing $ROOT_DIR/.env. Create it from backend/.env.example and set strong credentials before resetting." >&2 + exit 1 +fi + +docker compose down -v --remove-orphans + +if [[ "$KEEP_UPLOADS" != "--keep-uploads" ]]; then + if [[ -d "uploads" ]]; then + find "uploads" -mindepth 1 -delete + fi +fi + +docker compose up -d --build + +echo "Database reset complete. All users and app data are removed." + +live_backend_state="unavailable" +if backend_status_output="$(docker compose ps backend 2>/dev/null)"; then + if echo "$backend_status_output" | grep -q "Up"; then + live_backend_state="running" + else + live_backend_state="not running" + fi +fi + +echo +echo "===== Summary =====" +echo "Action: Deployment DB reset executed" +echo "Updated: Database recreated and services rebuilt" +echo "Live backend: $live_backend_state" diff --git a/backend/scripts/restore-deployment-db.ps1 b/backend/scripts/restore-deployment-db.ps1 new file mode 100644 index 0000000..1027f8c --- /dev/null +++ b/backend/scripts/restore-deployment-db.ps1 @@ -0,0 +1,155 @@ +param( + [string]$BackupDir = "backups\db", + [switch]$NoPause +) + +$ErrorActionPreference = "Stop" + +if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + $argumentList = @( + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-NoExit", + "-File", "`"$($MyInvocation.MyCommand.Path)`"" + ) + + if ($PSBoundParameters.ContainsKey("BackupDir")) { + $argumentList += @("-BackupDir", "`"$BackupDir`"") + } + if ($NoPause) { + $argumentList += "-NoPause" + } + + Start-Process powershell -Verb runAs -ArgumentList ($argumentList -join " ") + exit +} + +Write-Host "Restoring deployment database from backup..." -ForegroundColor Yellow + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$root = Resolve-Path (Join-Path $scriptDir "..") +$failed = $false + +try { + Push-Location $root + try { + $backupPath = Join-Path $root $BackupDir + if (-not (Test-Path $backupPath)) { + throw "Backup directory not found: $backupPath" + } + + $latest = Get-ChildItem -Path $backupPath -File -Filter "devbits-db-*.sql" | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 + + if (-not $latest) { + throw "No backup files found in $backupPath" + } + + $resolvedBackup = $latest.FullName + + Write-Host "Using backup: $resolvedBackup" -ForegroundColor Cyan + + $envFile = Join-Path $root ".env" + $dbUser = "devbits" + $dbName = "devbits" + if (Test-Path $envFile) { + $envRaw = Get-Content -Path $envFile -Raw + if ($envRaw -match "(?m)^POSTGRES_USER=(.+)$") { + $dbUser = $Matches[1].Trim() + } + if ($envRaw -match "(?m)^POSTGRES_DB=(.+)$") { + $dbName = $Matches[1].Trim() + } + } + + $terminateSql = "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='$dbName' AND pid <> pg_backend_pid();" + $dropDbSql = "DROP DATABASE IF EXISTS \"$dbName\";" + $createDbSql = "CREATE DATABASE \"$dbName\";" + + docker compose exec -T db psql -U $dbUser -d postgres -c $terminateSql + if ($LASTEXITCODE -ne 0) { + throw "Failed terminating active connections." + } + + docker compose exec -T db psql -U $dbUser -d postgres -c $dropDbSql + if ($LASTEXITCODE -ne 0) { + throw "Failed dropping existing database." + } + + docker compose exec -T db psql -U $dbUser -d postgres -c $createDbSql + if ($LASTEXITCODE -ne 0) { + throw "Failed creating target database." + } + + Get-Content -Path $resolvedBackup -Raw | docker compose exec -T db psql -U $dbUser -d $dbName + if ($LASTEXITCODE -ne 0) { + throw "Restore failed while applying SQL backup." + } + + $dbTimestamp = [System.IO.Path]::GetFileNameWithoutExtension($latest.Name) -replace '^devbits-db-', '' + $uploadsZip = Join-Path $backupPath ("devbits-uploads-" + $dbTimestamp + ".zip") + $uploadsTar = Join-Path $backupPath ("devbits-uploads-" + $dbTimestamp + ".tar.gz") + + $uploadsDir = Join-Path $root "uploads" + New-Item -ItemType Directory -Path $uploadsDir -Force | Out-Null + + if (Test-Path $uploadsZip) { + Get-ChildItem -Path $uploadsDir -File -ErrorAction SilentlyContinue | Remove-Item -Force + Expand-Archive -Path $uploadsZip -DestinationPath $uploadsDir -Force + Write-Host "Uploads restored from: $uploadsZip" -ForegroundColor Green + } + elseif (Test-Path $uploadsTar) { + Get-ChildItem -Path $uploadsDir -File -ErrorAction SilentlyContinue | Remove-Item -Force + tar -xzf $uploadsTar -C $uploadsDir + if ($LASTEXITCODE -ne 0) { + throw "Restore failed while extracting uploads archive." + } + Write-Host "Uploads restored from: $uploadsTar" -ForegroundColor Green + } + else { + Write-Host "No matching uploads backup found for timestamp $dbTimestamp, keeping current uploads directory." -ForegroundColor Yellow + } + + Write-Host "Restore complete. Rebuilding deployment services..." -ForegroundColor Yellow + + docker compose up -d --build + if ($LASTEXITCODE -ne 0) { + throw "Restore completed, but service rebuild failed." + } + + Write-Host "Restore complete and services rebuilt." -ForegroundColor Green + } + finally { + Pop-Location + } +} +catch { + $failed = $true + Write-Error $_ +} +finally { + $operationState = if ($failed) { "failed" } else { "success" } + $liveBackendState = "unavailable" + try { + $statusOutput = docker compose -f (Join-Path $root "docker-compose.yml") ps backend 2>$null + if ($LASTEXITCODE -eq 0) { + $liveBackendState = if (($statusOutput | Out-String) -match "Up") { "running" } else { "not running" } + } + } + catch {} + + Write-Host "" + Write-Host "===== Summary =====" -ForegroundColor Cyan + Write-Host "Action: Deployment DB restore executed ($operationState)" + Write-Host "Updated: Database restored and matching uploads restored when available; services rebuilt" + Write-Host "Live backend: $liveBackendState" + + if (-not $NoPause -and [Environment]::UserInteractive -and $Host.Name -eq "ConsoleHost") { + Read-Host "Press Enter to close" + } +} + +if ($failed) { + exit 1 +} diff --git a/backend/scripts/restore-deployment-db.sh b/backend/scripts/restore-deployment-db.sh new file mode 100644 index 0000000..d1e304b --- /dev/null +++ b/backend/scripts/restore-deployment-db.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +set -euo pipefail + +BACKUP_DIR="${1:-backups/db}" + +echo "Restoring deployment database from backup..." + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$ROOT_DIR" + +if [[ -f ".env" ]]; then + set -a + . ./.env + set +a +fi + +DB_USER="${POSTGRES_USER:-devbits}" +DB_NAME="${POSTGRES_DB:-devbits}" + +if [ "$EUID" -ne 0 ]; then + echo "Please run as root" + sudo "$0" "$@" + exit +fi + +if [[ ! -d "$BACKUP_DIR" ]]; then + echo "Backup directory not found: $BACKUP_DIR" >&2 + exit 1 +fi + +LATEST_FILE="$(ls -1t "$BACKUP_DIR"/devbits-db-*.sql 2>/dev/null | head -n 1 || true)" +if [[ -z "$LATEST_FILE" ]]; then + echo "No backup files found in $BACKUP_DIR" >&2 + exit 1 +fi + +RESOLVED_BACKUP="$LATEST_FILE" + +echo "Using backup: $RESOLVED_BACKUP" + +docker compose exec -T db psql -U "$DB_USER" -d postgres -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='${DB_NAME}' AND pid <> pg_backend_pid();" +docker compose exec -T db psql -U "$DB_USER" -d postgres -c "DROP DATABASE IF EXISTS \"${DB_NAME}\";" +docker compose exec -T db psql -U "$DB_USER" -d postgres -c "CREATE DATABASE \"${DB_NAME}\";" +docker compose exec -T db psql -U "$DB_USER" -d "$DB_NAME" < "$RESOLVED_BACKUP" + +db_file_name="$(basename "$RESOLVED_BACKUP")" +db_timestamp="${db_file_name#devbits-db-}" +db_timestamp="${db_timestamp%.sql}" + +uploads_tgz="$BACKUP_DIR/devbits-uploads-${db_timestamp}.tar.gz" +uploads_zip="$BACKUP_DIR/devbits-uploads-${db_timestamp}.zip" +uploads_dir="$ROOT_DIR/uploads" +mkdir -p "$uploads_dir" + +if [[ -f "$uploads_tgz" ]]; then + find "$uploads_dir" -mindepth 1 -maxdepth 1 -type f -delete + tar -xzf "$uploads_tgz" -C "$uploads_dir" + echo "Uploads restored from: $uploads_tgz" +elif [[ -f "$uploads_zip" ]]; then + find "$uploads_dir" -mindepth 1 -maxdepth 1 -type f -delete + unzip -o -q "$uploads_zip" -d "$uploads_dir" + echo "Uploads restored from: $uploads_zip" +else + echo "No matching uploads backup found for timestamp $db_timestamp, keeping current uploads directory." +fi + +echo "Restore complete. Rebuilding deployment services..." +docker compose up -d --build + +echo "Restore complete and services rebuilt." + +live_backend_state="unavailable" +if backend_status_output="$(docker compose ps backend 2>/dev/null)"; then + if echo "$backend_status_output" | grep -q "Up"; then + live_backend_state="running" + else + live_backend_state="not running" + fi +fi + +echo +echo "===== Summary =====" +echo "Action: Deployment DB restore executed" +echo "Updated: Database restored and matching uploads restored when available; services rebuilt" +echo "Live backend: $live_backend_state" diff --git a/backend/scripts/setup-daily-backup-task.ps1 b/backend/scripts/setup-daily-backup-task.ps1 new file mode 100644 index 0000000..4dfc746 --- /dev/null +++ b/backend/scripts/setup-daily-backup-task.ps1 @@ -0,0 +1,58 @@ +param( + [string]$TaskName = "DevBitsDailyDbBackup", + [string]$RunAt = "03:00", + [switch]$NoPause +) + +$ErrorActionPreference = "Stop" + +$scriptPath = Join-Path (Split-Path -Parent $MyInvocation.MyCommand.Path) "backup-deployment-db.ps1" +if (-not (Test-Path $scriptPath)) { + throw "Backup script not found: $scriptPath" +} + +$psExe = (Get-Command powershell.exe -ErrorAction Stop).Source +$taskAction = "`"$psExe`" -NoProfile -ExecutionPolicy Bypass -File `"$scriptPath`"" + +Write-Host "Registering Windows scheduled task '$TaskName' to run daily at $RunAt..." -ForegroundColor Yellow + +if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + $arguments = "& '" + $myinvocation.mycommand.definition + "'" + Start-Process powershell -Verb runAs -ArgumentList $arguments + exit +} + +schtasks /Create /F /SC DAILY /TN $TaskName /TR $taskAction /ST $RunAt /RU SYSTEM /RL HIGHEST | Out-Null + +if ($LASTEXITCODE -ne 0) { + Write-Warning "SYSTEM task creation failed (likely non-admin shell). Falling back to current user mode." + schtasks /Create /F /SC DAILY /TN $TaskName /TR $taskAction /ST $RunAt | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "Failed to create scheduled task in both SYSTEM and current-user modes." + } + Write-Warning "Task created in current user mode (Interactive only). Run this script as Administrator to use SYSTEM mode." +} + +Write-Host "Task created successfully." -ForegroundColor Green +Write-Host "Verify with: schtasks /Query /TN $TaskName /V /FO LIST" -ForegroundColor Green + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$root = Resolve-Path (Join-Path $scriptDir "..") +$liveBackendState = "unavailable" +try { + $statusOutput = docker compose -f (Join-Path $root "docker-compose.yml") ps backend 2>$null + if ($LASTEXITCODE -eq 0) { + $liveBackendState = if (($statusOutput | Out-String) -match "Up") { "running" } else { "not running" } + } +} +catch {} + +Write-Host "" +Write-Host "===== Summary =====" -ForegroundColor Cyan +Write-Host "Action: Daily backup task configured" +Write-Host "Updated: Scheduled task '$TaskName' set to run at $RunAt" +Write-Host "Live backend: $liveBackendState" + +if (-not $NoPause -and [Environment]::UserInteractive -and $Host.Name -eq "ConsoleHost") { + Read-Host "Press Enter to close" +} diff --git a/backend/scripts/update-live.ps1 b/backend/scripts/update-live.ps1 new file mode 100644 index 0000000..21b24b9 --- /dev/null +++ b/backend/scripts/update-live.ps1 @@ -0,0 +1,99 @@ +param( + [switch]$NoPause +) + +if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + $arguments = "& '" + $myinvocation.mycommand.definition + "'" + Start-Process powershell -Verb runAs -ArgumentList $arguments + exit +} + +$ErrorActionPreference = "Stop" + +Write-Host "Updating the live application by rebuilding and restarting the backend service..." -ForegroundColor Yellow + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$root = Resolve-Path (Join-Path $scriptDir "..") +Push-Location $root + +try { + $envFile = Join-Path $root ".env" + if (-not (Test-Path $envFile)) { + throw "Missing $envFile. Create it from backend/.env.example and set strong credentials before deploying." + } + + $envContent = Get-Content -Path $envFile -Raw + if ($envContent -notmatch "(?m)^POSTGRES_PASSWORD=.+$") { + throw "POSTGRES_PASSWORD is not set in $envFile." + } + if ($envContent -match "(?m)^POSTGRES_PASSWORD=(password|changeme|devbits)$") { + throw "POSTGRES_PASSWORD in $envFile is weak/default. Set a strong random value before deploying." + } + + # Ensure DB is started first so we can sync credentials to avoid auth mismatches. + docker compose up -d db + + # Wait for DB health (up to ~60s) + $dbHealthy = $false + for ($attempt = 0; $attempt -lt 30; $attempt++) { + $statusOutput = docker compose ps db 2>$null | Out-String + if ($statusOutput -match "Up" -and $statusOutput -match "healthy") { + $dbHealthy = $true + break + } + Start-Sleep -Seconds 2 + } + + if (-not $dbHealthy) { + Write-Host "Warning: DB did not reach healthy state in time; proceeding to attempt password sync anyway." -ForegroundColor Yellow + } + + # Sync the POSTGRES_PASSWORD from .env into the Postgres role to keep credentials consistent. + try { + $envRaw = Get-Content -Path $envFile -Raw + if ($envRaw -match "(?m)^POSTGRES_PASSWORD=(.+)$") { + $dbPass = $Matches[1].Trim() + if ($dbPass -and $dbPass -notmatch "^(password|changeme|devbits)$") { + $tmpFile = Join-Path $env:TEMP "sync-devbits-password.sql" + $safe = $dbPass.Replace("'", "''") + Set-Content -Path $tmpFile -Value "ALTER ROLE devbits WITH PASSWORD '$safe';" -NoNewline + try { + Get-Content $tmpFile -Raw | docker compose exec -T db sh -lc "psql -U devbits -d postgres" | Out-Null + Write-Host "Synchronized Postgres role password to match .env" -ForegroundColor Green + } + catch { + Write-Host "Warning: Could not run password sync command inside DB container: $_" -ForegroundColor Yellow + } + Remove-Item $tmpFile -Force -ErrorAction SilentlyContinue + } + } + } + catch { + Write-Host "Warning: Failed to read .env or sync password: $_" -ForegroundColor Yellow + } + + docker compose up -d --build backend nginx + Write-Host "Backend and nginx services have been updated." -ForegroundColor Green +} +finally { + Pop-Location +} + +$liveBackendState = "unavailable" +try { + $statusOutput = docker compose -f (Join-Path $root "docker-compose.yml") ps backend 2>$null + if ($LASTEXITCODE -eq 0) { + $liveBackendState = if (($statusOutput | Out-String) -match "Up") { "running" } else { "not running" } + } +} +catch {} + +Write-Host "" +Write-Host "===== Summary =====" -ForegroundColor Cyan +Write-Host "Action: Live backend update executed" +Write-Host "Updated: Backend rebuilt; nginx refreshed" +Write-Host "Live backend: $liveBackendState" + +if (-not $NoPause -and [Environment]::UserInteractive -and $Host.Name -eq "ConsoleHost") { + Read-Host "Press Enter to close" +} diff --git a/backend/scripts/update-live.sh b/backend/scripts/update-live.sh new file mode 100644 index 0000000..8ae65b5 --- /dev/null +++ b/backend/scripts/update-live.sh @@ -0,0 +1,53 @@ +#!/bin/bash + +if [ "$EUID" -ne 0 ]; then + echo "Please run as root" + sudo "$0" "$@" + exit +fi + +set -e + +echo "Updating the live application by rebuilding and restarting the backend service..." + +# Get the directory of the script +script_dir=$(dirname "$(readlink -f "$0")") +root_dir=$(realpath "$script_dir/..") + +# Change to the root directory of the backend +cd "$root_dir" + +env_file="$root_dir/.env" +if [[ ! -f "$env_file" ]]; then + echo "Missing $env_file. Create it from backend/.env.example and set strong credentials before deploying." >&2 + exit 1 +fi + +postgres_password="$(grep -E '^POSTGRES_PASSWORD=' "$env_file" | tail -n 1 | cut -d '=' -f2- || true)" +if [[ -z "$postgres_password" ]]; then + echo "POSTGRES_PASSWORD is not set in $env_file" >&2 + exit 1 +fi +if [[ "$postgres_password" == "password" || "$postgres_password" == "changeme" || "$postgres_password" == "devbits" ]]; then + echo "POSTGRES_PASSWORD in $env_file is weak/default. Set a strong random value before deploying." >&2 + exit 1 +fi + +docker compose up -d --build backend nginx + +echo "Backend and nginx services have been updated." + +live_backend_state="unavailable" +if backend_status_output="$(docker compose ps backend 2>/dev/null)"; then + if echo "$backend_status_output" | grep -q "Up"; then + live_backend_state="running" + else + live_backend_state="not running" + fi +fi + +echo +echo "===== Summary =====" +echo "Action: Live backend update executed" +echo "Updated: Backend rebuilt; nginx refreshed" +echo "Live backend: $live_backend_state" diff --git a/backend/uploads/u2_34db848e14529523fe699c2e.jpg b/backend/uploads/u2_34db848e14529523fe699c2e.jpg new file mode 100644 index 0000000..fab5301 Binary files /dev/null and b/backend/uploads/u2_34db848e14529523fe699c2e.jpg differ diff --git a/backend/uploads/u3_8e213dd74950c62c9df55994.jpg b/backend/uploads/u3_8e213dd74950c62c9df55994.jpg new file mode 100644 index 0000000..95d092a Binary files /dev/null and b/backend/uploads/u3_8e213dd74950c62c9df55994.jpg differ diff --git a/frontend/.gitignore b/frontend/.gitignore index c9d575d..22c1ea9 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -32,6 +32,19 @@ yarn-error.* # local env files .env*.local +# Secrets and credentials +*service-account*.json +devbits-*.json +devbits-play-service-account.json +google-services.json +GoogleService-Info.plist +fastlane/credentials.json + +# Ignore any exported keys +*.key +*.jks +*.keystore + # typescript *.tsbuildinfo diff --git a/frontend/app.json b/frontend/app.json index 91ade24..cb50f3d 100644 --- a/frontend/app.json +++ b/frontend/app.json @@ -1,41 +1,73 @@ { "expo": { - "name": "frontend", + "name": "DevBits", "slug": "frontend", - "version": "1.0.0", + "version": "1.0.2", "orientation": "portrait", - "icon": "./assets/images/icon.png", + "icon": "./assets/images/Devbits_Icons1.png", "scheme": "myapp", "userInterfaceStyle": "automatic", "newArchEnabled": true, "ios": { - "supportsTablet": true + "supportsTablet": true, + "icon": "./assets/images/Devbits_Icons1.png", + "bundleIdentifier": "com.devbits.frontend", + "buildNumber": "2", + "infoPlist": { + "ITSAppUsesNonExemptEncryption": false + } }, "android": { + "icon": "./assets/images/Devbits_Icons1.png", + "versionCode": 13, "adaptiveIcon": { "foregroundImage": "./assets/images/adaptive-icon.png", - "backgroundColor": "#ffffff" - } + "backgroundColor": "#000000" + }, + "permissions": [ + "android.permission.RECORD_AUDIO" + ], + "package": "com.devbits.frontend" }, "web": { "bundler": "metro", "output": "static", "favicon": "./assets/images/favicon.png" }, + "splash": { + "image": "./assets/images/Devbits_Icons1.png", + "resizeMode": "contain", + "backgroundColor": "#000000" + }, "plugins": [ "expo-router", + "expo-notifications", + [ + "expo-image-picker", + { + "photosPermission": "Allow DevBits to access your photos." + } + ], [ "expo-splash-screen", { "image": "./assets/images/splash-icon.png", "imageWidth": 200, "resizeMode": "contain", - "backgroundColor": "#ffffff" + "backgroundColor": "#000000" } - ] + ], + "expo-secure-store", + "expo-video" ], "experiments": { "typedRoutes": true + }, + "extra": { + "router": {}, + "eas": { + "projectId": "2286e55a-f086-446f-bcb5-3e36b65a79f6" + } } } } diff --git a/frontend/app/(auth)/_layout.tsx b/frontend/app/(auth)/_layout.tsx new file mode 100644 index 0000000..cc1cf7f --- /dev/null +++ b/frontend/app/(auth)/_layout.tsx @@ -0,0 +1,6 @@ +import { Stack } from "expo-router"; +import React from "react"; + +export default function AuthLayout() { + return ; +} diff --git a/frontend/app/(auth)/sign-in.tsx b/frontend/app/(auth)/sign-in.tsx new file mode 100644 index 0000000..066e267 --- /dev/null +++ b/frontend/app/(auth)/sign-in.tsx @@ -0,0 +1,200 @@ +import React, { useEffect, useRef, useState } from "react"; +import { + ActivityIndicator, + Animated, + Pressable, + StyleSheet, + TextInput, + View, +} from "react-native"; +import { Link } from "expo-router"; +import { + SafeAreaView, + useSafeAreaInsets, +} from "react-native-safe-area-context"; +import { ThemedText } from "@/components/ThemedText"; +import { useAuth } from "@/contexts/AuthContext"; +import { useAppColors } from "@/hooks/useAppColors"; +import { useMotionConfig } from "@/hooks/useMotionConfig"; +import { checkApiConnection } from "@/services/api"; + +export default function SignInScreen() { + const colors = useAppColors(); + const insets = useSafeAreaInsets(); + const { signIn } = useAuth(); + const motion = useMotionConfig(); + const reveal = useRef(new Animated.Value(0.08)).current; + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + + useEffect(() => { + if (motion.prefersReducedMotion) { + reveal.setValue(1); + return; + } + + Animated.timing(reveal, { + toValue: 1, + duration: motion.duration(320), + useNativeDriver: true, + }).start(); + }, [motion, reveal]); + + const handleSubmit = async () => { + if (!username || !password) { + setErrorMessage("Enter username and password."); + return; + } + setIsSubmitting(true); + setErrorMessage(""); + try { + await checkApiConnection(); + await signIn({ username, password }); + } catch (error) { + setErrorMessage( + error instanceof Error + ? error.message + : "Sign in failed. Check credentials and connection.", + ); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + + + DevBits + + Sign in to ship bytes. + + + + + + + + + + + + {errorMessage ? ( + + {errorMessage} + + ) : null} + + + {isSubmitting ? ( + + ) : ( + + Sign in + + )} + + + + + + New here? + + + Create account + + + + + ); +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + }, + content: { + flex: 1, + paddingHorizontal: 16, + paddingBottom: 24, + justifyContent: "space-between", + }, + header: { + marginTop: 20, + gap: 6, + }, + form: { + gap: 12, + }, + inputRow: { + borderRadius: 12, + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 10, + }, + input: { + fontFamily: "SpaceMono", + fontSize: 15, + }, + button: { + borderRadius: 12, + paddingVertical: 12, + alignItems: "center", + }, + footer: { + flexDirection: "row", + gap: 8, + alignItems: "center", + }, +}); diff --git a/frontend/app/(auth)/sign-up.tsx b/frontend/app/(auth)/sign-up.tsx new file mode 100644 index 0000000..d4483c3 --- /dev/null +++ b/frontend/app/(auth)/sign-up.tsx @@ -0,0 +1,204 @@ +import React, { useEffect, useRef, useState } from "react"; +import { + ActivityIndicator, + Animated, + Pressable, + StyleSheet, + TextInput, + View, +} from "react-native"; +import { Link } from "expo-router"; +import { + SafeAreaView, + useSafeAreaInsets, +} from "react-native-safe-area-context"; +import { ThemedText } from "@/components/ThemedText"; +import { useAuth } from "@/contexts/AuthContext"; +import { useAppColors } from "@/hooks/useAppColors"; +import { useMotionConfig } from "@/hooks/useMotionConfig"; +import { checkApiConnection } from "@/services/api"; + +export default function SignUpScreen() { + const colors = useAppColors(); + const insets = useSafeAreaInsets(); + const { signUp } = useAuth(); + const motion = useMotionConfig(); + const reveal = useRef(new Animated.Value(0.08)).current; + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + + useEffect(() => { + if (motion.prefersReducedMotion) { + reveal.setValue(1); + return; + } + + Animated.timing(reveal, { + toValue: 1, + duration: motion.duration(320), + useNativeDriver: true, + }).start(); + }, [motion, reveal]); + + const handleSubmit = async () => { + if (!username || !password) { + setErrorMessage("Enter username and password."); + return; + } + if (password.length < 6) { + setErrorMessage("Password must be at least 6 characters."); + return; + } + setIsSubmitting(true); + setErrorMessage(""); + try { + await checkApiConnection(); + await signUp({ username, password }); + } catch (error) { + setErrorMessage( + error instanceof Error + ? error.message + : "Sign up failed. Try a new username or check connection.", + ); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + + + Create account + + Set up a new handle and start shipping. + + + + + + + + + + + + {errorMessage ? ( + + {errorMessage} + + ) : null} + + + {isSubmitting ? ( + + ) : ( + + Create account + + )} + + + + + + Already have an account? + + + Sign in + + + + + ); +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + }, + content: { + flex: 1, + paddingHorizontal: 16, + paddingBottom: 24, + justifyContent: "space-between", + }, + header: { + marginTop: 20, + gap: 6, + }, + form: { + gap: 12, + }, + inputRow: { + borderRadius: 12, + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 10, + }, + input: { + fontFamily: "SpaceMono", + fontSize: 15, + }, + button: { + borderRadius: 12, + paddingVertical: 12, + alignItems: "center", + }, + footer: { + flexDirection: "row", + gap: 8, + alignItems: "center", + }, +}); diff --git a/frontend/app/(tabs)/_layout.tsx b/frontend/app/(tabs)/_layout.tsx index 153cdea..4657488 100644 --- a/frontend/app/(tabs)/_layout.tsx +++ b/frontend/app/(tabs)/_layout.tsx @@ -3,27 +3,37 @@ import React from "react"; import { Platform } from "react-native"; import { HapticTab } from "@/components/HapticTab"; import { IconSymbol } from "@/components/ui/IconSymbol"; -import TabBarBackground from "@/components/ui/TabBarBackground"; -import { Colors } from "@/constants/Colors"; -import { useColorScheme } from "@/hooks/useColorScheme"; +import { useAppColors } from "@/hooks/useAppColors"; +import { usePreferences } from "@/contexts/PreferencesContext"; export default function TabLayout() { - const colorScheme = useColorScheme(); + const colors = useAppColors(); + const { preferences } = usePreferences(); + const tabPadding = preferences.compactMode ? 2 : 6; return ( - - Explore - - - This app includes example code to help you get started. - - - - This app has two screens:{" "} - app/(tabs)/index.tsx{" "} - and{" "} - app/(tabs)/explore.tsx - - - The layout file in{" "} - app/(tabs)/_layout.tsx{" "} - sets up the tab navigator. - - - Learn more - - - - - You can open this project on Android, iOS, and the web. To open the - web version, press w{" "} - in the terminal running this project. - - - - - For static images, you can use the{" "} - @2x and{" "} - @3x suffixes to - provide files for different screen densities - - - - Learn more - - - - - Open app/_layout.tsx{" "} - to see how to load{" "} - - custom fonts such as this one. - - - - Learn more - - - - - This template has light and dark mode support. The{" "} - useColorScheme() hook - lets you inspect what the user's current color scheme is, and so you - can adjust UI colors accordingly. - - - Learn more - - - - - This template includes an example of an animated component. The{" "} - - components/HelloWave.tsx - {" "} - component uses the powerful{" "} - - react-native-reanimated - {" "} - library to create a waving hand animation. - - {Platform.select({ - ios: ( - - The{" "} - - components/ParallaxScrollView.tsx - {" "} - component provides a parallax effect for the header image. - +const categories = [ + "None", + "Search results", + "All", + "bytes", + "Streams", + "users", + "Tags", +]; + +export default function ExploreScreen() { + const colors = useAppColors(); + const insets = useSafeAreaInsets(); + const router = useRouter(); + const { user } = useAuth(); + const { preferences } = usePreferences(); + const [activeCategory, setActiveCategory] = useState("None"); + const [projects, setProjects] = useState( + [] as ReturnType[], + ); + const [posts, setPosts] = useState([] as ReturnType[]); + const [tags, setTags] = useState([] as string[]); + const [people, setPeople] = useState([]); + const [following, setFollowing] = useState>(new Set()); + const [searchTerm, setSearchTerm] = useState(""); + const [searchProjects, setSearchProjects] = useState( + [] as ReturnType[], + ); + const [searchPosts, setSearchPosts] = useState( + [] as ReturnType[], + ); + const [searchPeople, setSearchPeople] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [hasError, setHasError] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + const motion = useMotionConfig(); + const requestGuard = useRequestGuard(); + const reveal = useRef(new Animated.Value(0.08)).current; + const hasFocusedRef = useRef(false); + const scrollRef = useRef(null); + const { scrollY, onScroll } = useTopBlurScroll(); + + useEffect(() => { + if (motion.prefersReducedMotion) { + reveal.setValue(1); + return; + } + + Animated.spring(reveal, { + toValue: 1, + speed: 16, + bounciness: 7, + useNativeDriver: true, + }).start(); + }, [motion, reveal]); + + const loadExplore = useCallback( + async (showLoader = true) => { + const requestId = requestGuard.beginRequest(); + try { + if (showLoader && requestGuard.isMounted()) { + setIsLoading(true); + } + const [projectFeedRaw, postFeedRaw, usersRaw, followingIdsRaw] = + await Promise.all([ + getProjectsFeed("time", 0, 8), + getPostsFeed("time", 0, 8), + getAllUsers(0, 50), + user?.username + ? getUsersFollowing(user.username) + : Promise.resolve([]), + ]); + + const projectFeed = Array.isArray(projectFeedRaw) ? projectFeedRaw : []; + const postFeed = Array.isArray(postFeedRaw) ? postFeedRaw : []; + const users = Array.isArray(usersRaw) ? usersRaw : []; + const followingIds = Array.isArray(followingIdsRaw) + ? followingIdsRaw + : []; + + const builderCounts = await Promise.all( + projectFeed.map((project) => + getProjectBuilders(project.id).catch(() => []), + ), + ); + const uiProjects = projectFeed.map((project, index) => + mapProjectToUi(project, builderCounts[index]?.length ?? 0), + ); + const projectMap = new Map( + projectFeed.map((project) => [project.id, project]), + ); + const uiPosts = await Promise.all( + postFeed.map(async (post) => { + const [postUser, postProject] = await Promise.all([ + getUserById(post.user).catch(() => null), + projectMap.get(post.project) + ? Promise.resolve(projectMap.get(post.project)!) + : getProjectById(post.project).catch(() => null), + ]); + return mapPostToUi(post, postUser, postProject); + }), + ); + const tagCounts = new Map(); + uiProjects.forEach((project) => + project.tags.forEach((tag) => + tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1), ), - })} - - + ); + uiPosts.forEach((post) => + post.tags.forEach((tag) => + tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1), + ), + ); + + const uiPeople = users.map((item) => ({ + id: item.id, + name: item.username, + title: "", + focus: item.bio ?? "", + picture: item.picture ? resolveMediaUrl(item.picture) : undefined, + })); + + if (!requestGuard.isActive(requestId)) { + return; + } + + setProjects(uiProjects); + setPosts(uiPosts); + setTags( + Array.from(tagCounts.entries()) + .sort((a, b) => b[1] - a[1]) + .map(([tag]) => tag), + ); + setPeople(uiPeople.filter((person) => person.name !== user?.username)); + setFollowing(new Set(followingIds)); + setHasError(false); + } catch { + if (!requestGuard.isActive(requestId)) { + return; + } + setProjects([]); + setPosts([]); + setTags([]); + setPeople([]); + setHasError(true); + } finally { + if (showLoader && requestGuard.isMounted()) { + setIsLoading(false); + } + } + }, + [requestGuard, user?.username], + ); + + useEffect(() => { + loadExplore(); + }, [loadExplore]); + + useEffect(() => { + return subscribeToPostEvents((event) => { + setPosts((prev) => { + if (event.type === "updated") { + return prev.map((post) => + post.id === event.postId + ? { + ...post, + content: event.content, + media: event.media ?? post.media, + } + : post, + ); + } + if (event.type === "stats") { + return prev.map((post) => + post.id === event.postId + ? { + ...post, + likes: event.likes ?? post.likes, + comments: event.comments ?? post.comments, + } + : post, + ); + } + if (event.type === "deleted") { + return prev.filter((post) => post.id !== event.postId); + } + return prev; + }); + }); + }, []); + + useFocusEffect( + useCallback(() => { + if (!hasFocusedRef.current) { + hasFocusedRef.current = true; + return; + } + + const task = InteractionManager.runAfterInteractions(() => { + beginFreshReadWindow(); + void loadExplore(false); + }); + + return () => { + task.cancel?.(); + }; + }, [loadExplore]), + ); + + const handleRefresh = useCallback(async () => { + setIsRefreshing(true); + beginFreshReadWindow(); + await loadExplore(false); + setIsRefreshing(false); + }, [loadExplore]); + + useAutoRefresh(() => loadExplore(false), { focusRefresh: false }); + + const filteredProjects = useMemo(() => { + const term = searchTerm.trim().toLowerCase(); + return projects.filter((project) => { + const matchesTerm = term + ? project.name.toLowerCase().includes(term) || + project.summary.toLowerCase().includes(term) || + project.tags.some((tag) => tag.toLowerCase().includes(term)) + : true; + return matchesTerm; + }); + }, [projects, searchTerm]); + + const filteredPosts = useMemo(() => { + const term = searchTerm.trim().toLowerCase(); + return posts.filter((post) => { + if (!term) { + return true; + } + return ( + post.content.toLowerCase().includes(term) || + post.projectName.toLowerCase().includes(term) || + post.tags.some((tag) => tag.toLowerCase().includes(term)) + ); + }); + }, [posts, searchTerm]); + + const filteredPeople = useMemo(() => { + const term = searchTerm.trim().toLowerCase(); + if (!term) { + return people; + } + return people.filter( + (person) => + person.name.toLowerCase().includes(term) || + person.focus.toLowerCase().includes(term), + ); + }, [people, searchTerm]); + + const scoreMatch = (text: string, term: string) => { + const trimmed = term.trim().toLowerCase(); + if (!trimmed) { + return 0; + } + const haystack = text.toLowerCase(); + let score = 0; + if (haystack.includes(trimmed)) { + score += 4; + } + const tokens = trimmed.split(/\s+/).filter(Boolean); + tokens.forEach((token) => { + if (haystack.includes(token)) { + score += 1; + } + if (haystack.startsWith(token)) { + score += 1; + } + }); + return score; + }; + + const handleSearchSubmit = useCallback(async () => { + const term = searchTerm.trim(); + if (!term) { + setActiveCategory("None"); + setSearchProjects([]); + setSearchPosts([]); + setSearchPeople([]); + return; + } + + setActiveCategory("Search results"); + setIsSearching(true); + + const scoredUsers = people + .map((person) => ({ + person, + score: scoreMatch(`${person.name} ${person.focus}`, term), + })) + .filter((entry) => entry.score > 0) + .sort((a, b) => b.score - a.score) + .map((entry) => entry.person); + + const scoredProjects = projects + .map((project) => ({ + project, + score: scoreMatch( + `${project.name} ${project.summary} ${project.tags.join(" ")}`, + term, + ), + })) + .filter((entry) => entry.score > 0) + .sort((a, b) => b.score - a.score) + .map((entry) => entry.project); + + const scoredPosts = posts + .map((post) => ({ + post, + score: scoreMatch( + `${post.content} ${post.projectName} ${post.tags.join(" ")}`, + term, + ), + })) + .filter((entry) => entry.score > 0) + .sort((a, b) => b.score - a.score) + .map((entry) => entry.post); + + try { + const [userProjectsList, userPostsList] = await Promise.all([ + Promise.all( + scoredUsers.map((person) => + getProjectsByUserId(person.id).catch(() => []), + ), + ), + Promise.all( + scoredUsers.map((person) => + getPostsByUserId(person.id).catch(() => []), + ), + ), + ]); + + const extraProjects = userProjectsList.flat(); + const userPosts = userPostsList.flat(); + const extraPostsUi = await Promise.all( + userPosts.map(async (post) => { + const [postUser, postProject] = await Promise.all([ + getUserById(post.user).catch(() => null), + getProjectById(post.project).catch(() => null), + ]); + return mapPostToUi(post, postUser, postProject); + }), + ); + + const extraBuilderCounts = await Promise.all( + extraProjects.map((project) => + getProjectBuilders(project.id).catch(() => []), + ), + ); + const extraProjectsUi = extraProjects.map((project, index) => + mapProjectToUi(project, extraBuilderCounts[index]?.length ?? 0), + ); + const mergedProjects = [...scoredProjects, ...extraProjectsUi]; + const uniqueProjects = Array.from( + new Map( + mergedProjects.map((project) => [project.id, project]), + ).values(), + ); + + const mergedPosts = [...scoredPosts, ...extraPostsUi]; + const uniquePosts = Array.from( + new Map(mergedPosts.map((post) => [post.id, post])).values(), + ); + + setSearchPeople(scoredUsers); + setSearchProjects(uniqueProjects); + setSearchPosts(uniquePosts); + } finally { + setIsSearching(false); + } + }, [people, posts, projects, searchTerm]); + + const handleTagSearch = (tag: string) => { + setSearchTerm(tag); + setActiveCategory("All"); + }; + + const handleFollowToggle = async (target: UiPerson) => { + if (!user?.username) { + return; + } + const isFollowing = following.has(target.id); + try { + if (isFollowing) { + await unfollowUser(user.username, target.name); + } else { + await followUser(user.username, target.name); + } + setFollowing((prev) => { + const next = new Set(prev); + if (isFollowing) { + next.delete(target.id); + } else { + next.add(target.id); + } + return next; + }); + } catch { + // keep current state on failure + } + }; + + const activeKey = activeCategory.toLowerCase(); + const showSearchResults = activeKey === "search results"; + const showStreams = activeKey === "streams" || activeKey === "all"; + const showBytes = activeKey === "bytes" || activeKey === "all"; + const showUsers = activeKey === "users" || activeKey === "all"; + const showTags = activeKey === "tags" || activeKey === "all"; + const isRetro = preferences.visualizationMode === "retro"; + + return ( + + + + + + } + contentContainerStyle={[ + styles.container, + { paddingTop: 8, paddingBottom: 96 + insets.bottom }, + ]} + > + + + Explore + + + Find streams, bytes, and builders. + + + + + + void handleSearchSubmit()} + returnKeyType="search" + style={[styles.searchInput, { color: colors.text }]} + /> + + + + + {categories.map((category) => { + const isActive = category === activeCategory; + return ( + setActiveCategory(category)} + style={[ + styles.categoryChip, + { + backgroundColor: isActive + ? colors.tint + : colors.surfaceAlt, + borderColor: colors.border, + borderRadius: isRetro ? 3 : 10, + }, + ]} + > + + {category} + + + ); + })} + + + + {showSearchResults ? ( + + + {isSearching ? ( + + + + Searching... + + + ) : ( + + + + + {searchPeople.length ? ( + searchPeople.map((person) => { + const isFollowing = following.has(person.id); + return ( + + router.push({ + pathname: "/user/[username]", + params: { username: person.name }, + }) + } + > + + + {person.picture ? ( + + ) : ( + + {person.name[0]} + + )} + + + + {person.name} + + + + handleFollowToggle(person)} + style={[ + styles.followButton, + { backgroundColor: colors.tint }, + ]} + > + + {isFollowing ? "Following" : "Follow"} + + + + ); + }) + ) : ( + + + No users found. + + + )} + + + + + {searchProjects.length ? ( + + String(project.id)} + renderItem={(project) => ( + + )} + /> + + ) : ( + + + No streams found. + + + )} + + + + {searchPosts.length ? ( + searchPosts.map((post) => ( + + )) + ) : ( + + + No bytes found. + + + )} + + + )} + + ) : null} + + {showStreams ? ( + + router.push("/streams")} + /> + {isLoading ? ( + + + + ) : filteredProjects.length ? ( + + String(project.id)} + renderItem={(project) => ( + + )} + /> + + ) : ( + + + {hasError + ? "Spotlight unavailable. Check the API and try again." + : "No streams yet."} + + + )} + + ) : null} + + {showBytes ? ( + + router.push("/bytes")} + /> + {isLoading ? ( + + ) : filteredPosts.length ? ( + filteredPosts.map((post) => ) + ) : ( + + + {hasError ? "Bytes unavailable." : "No bytes yet."} + + + )} + + ) : null} + + {showUsers ? ( + + + + {filteredPeople.length ? ( + filteredPeople.map((person) => { + const isFollowing = following.has(person.id); + return ( + + router.push({ + pathname: "/user/[username]", + params: { username: person.name }, + }) + } + > + + + {person.picture ? ( + + ) : ( + + {person.name[0]} + + )} + + + + {person.name} + + {person.title ? ( + + {person.title} + + ) : null} + + + handleFollowToggle(person)} + style={[ + styles.followButton, + { backgroundColor: colors.tint }, + ]} + > + + {isFollowing ? "Following" : "Follow"} + + + + ); + }) + ) : ( + + + {hasError + ? "People feed unavailable." + : "No builders yet."} + + + )} + + + ) : null} + + {showTags ? ( + + + + Tap a tag to search. + + {isLoading ? ( + + ) : tags.length ? ( + + {tags.map((tag) => ( + handleTagSearch(tag)}> + + + ))} + + ) : ( + + + No tags yet. + + + )} + + ) : null} + + + + + scrollRef.current?.scrollTo({ y: 0, animated: true })} + bottomOffset={insets.bottom + 20} + /> + ); } const styles = StyleSheet.create({ - headerImage: { - color: "#808080", - bottom: -90, - left: -35, - position: "absolute", + screen: { + flex: 1, + }, + safeArea: { + flex: 1, + }, + background: { + ...StyleSheet.absoluteFillObject, + }, + container: { + paddingVertical: 16, + paddingHorizontal: 16, + gap: 20, + paddingTop: 0, + }, + edgeToEdgeRail: { + marginHorizontal: -16, + }, + title: { + fontSize: 26, + lineHeight: 30, + }, + searchBar: { + flexDirection: "row", + alignItems: "center", + gap: 10, + borderRadius: 12, + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 8, + }, + searchInput: { + flex: 1, + fontFamily: "SpaceMono", + }, + categoryRow: { + flexDirection: "row", + gap: 8, + paddingVertical: 2, + paddingHorizontal: 16, + }, + categoryChip: { + paddingVertical: 6, + paddingHorizontal: 10, + borderRadius: 10, + borderWidth: 1, + }, + projectRow: { + flexDirection: "row", + gap: 12, + paddingVertical: 4, + paddingHorizontal: 16, }, - titleContainer: { + skeletonStack: { + gap: 12, + }, + skeletonCard: { + width: 220, + height: 110, + borderRadius: 14, + borderWidth: 1, + opacity: 0.7, + }, + skeletonChip: { + height: 22, + width: 70, + borderRadius: 8, + opacity: 0.7, + }, + tagGrid: { flexDirection: "row", + flexWrap: "wrap", + gap: 8, + }, + peopleGrid: { + gap: 12, + }, + searchStack: { + gap: 20, + }, + loadingState: { + alignItems: "center", gap: 8, + paddingVertical: 20, + }, + emptyState: { + alignItems: "center", + paddingVertical: 16, + }, + personCard: { + borderRadius: 14, + padding: 12, + borderWidth: 1, + gap: 10, + }, + personHeader: { + flexDirection: "row", + alignItems: "center", + gap: 10, + }, + personAvatar: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: "center", + justifyContent: "center", + overflow: "hidden", + }, + personAvatarImage: { + width: "100%", + height: "100%", + }, + personText: { + flex: 1, + }, + followButton: { + alignSelf: "flex-start", + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 10, }, }); diff --git a/frontend/app/(tabs)/index.tsx b/frontend/app/(tabs)/index.tsx index dbada47..1438779 100644 --- a/frontend/app/(tabs)/index.tsx +++ b/frontend/app/(tabs)/index.tsx @@ -1,79 +1,659 @@ -import React, { useState, useRef } from "react"; -import { Animated, StyleSheet, ScrollView, SafeAreaView, View } from "react-native"; -import { Post } from "@/components/Post"; -import CreatePost from "@/components/CreatePost"; -import { MyFilter } from "@/components/filter"; +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { + Animated, + InteractionManager, + Platform, + RefreshControl, + StyleSheet, + View, +} from "react-native"; +import { + SafeAreaView, + useSafeAreaInsets, +} from "react-native-safe-area-context"; +import { useRootNavigationState, useRouter } from "expo-router"; +import { useFocusEffect } from "@react-navigation/native"; +import { + beginFreshReadWindow, + getPostsFeed, + getProjectBuilders, + getProjectsByBuilderId, + getProjectsFeed, + getUserById, +} from "@/services/api"; +import { mapPostToUi, mapProjectToUi } from "@/services/mappers"; +import { useAppColors } from "@/hooks/useAppColors"; +import { useAutoRefresh } from "@/hooks/useAutoRefresh"; +import { useMotionConfig } from "@/hooks/useMotionConfig"; +import { useRequestGuard } from "@/hooks/useRequestGuard"; import { MyHeader } from "@/components/header"; -import ScrollToTopButton from "@/components/ScrollToTopButton"; +import CreatePost from "@/components/CreatePost"; +import { Post } from "@/components/Post"; +import { ProjectCard } from "@/components/ProjectCard"; +import { InfiniteHorizontalCycle } from "@/components/InfiniteHorizontalCycle"; +import { SectionHeader } from "@/components/SectionHeader"; +import { ThemedText } from "@/components/ThemedText"; +import { FloatingScrollTopButton } from "@/components/FloatingScrollTopButton"; +import { TopBlur } from "@/components/TopBlur"; +import { UnifiedLoadingList } from "@/components/UnifiedLoading"; +import { useTopBlurScroll } from "@/hooks/useTopBlurScroll"; +import { subscribeToPostEvents } from "@/services/postEvents"; +import { + applyProjectEvent, + subscribeToProjectEvents, +} from "@/services/projectEvents"; +import { useAuth } from "@/contexts/AuthContext"; +import { useSavedStreams } from "@/contexts/SavedStreamsContext"; +import Reanimated, { + Easing as ReanimatedEasing, + useAnimatedStyle, + useSharedValue, + withDelay, + withRepeat, + withSpring, + withTiming, +} from "react-native-reanimated"; +import { useHyprMotion } from "@/hooks/useHyprMotion"; export default function HomeScreen() { - const [scrollY] = useState(new Animated.Value(0)); - const scrollViewRef = useRef(null); - const headerOpacity = scrollY.interpolate({ - inputRange: [0, 200], - outputRange: [1, 0], - extrapolate: "clamp", - }); - const topscrollOpacity = scrollY.interpolate({ - inputRange: [300, 500], - outputRange: [0, 1], - extrapolate: "clamp", - }); + const colors = useAppColors(); + const insets = useSafeAreaInsets(); + const router = useRouter(); + const rootNavigationState = useRootNavigationState(); + const { user } = useAuth(); + const { savedProjectIds } = useSavedStreams(); + const [posts, setPosts] = useState([] as ReturnType[]); + const [projects, setProjects] = useState( + [] as ReturnType[], + ); + const [builderProjectIds, setBuilderProjectIds] = useState([]); + const [tags, setTags] = useState([] as string[]); + const [isLoading, setIsLoading] = useState(true); + const [hasError, setHasError] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + const motion = useMotionConfig(); + const hyprMotion = useHyprMotion(); + const requestGuard = useRequestGuard(); + const scrollRef = useRef(null); + const hasFocusedRef = useRef(false); + const { scrollY, onScroll } = useTopBlurScroll(); + const heroProgress = useSharedValue(0.08); + const streamsProgress = useSharedValue(0.08); + const postsProgress = useSharedValue(0.08); + const cursorOpacity = useSharedValue(1); + const homePostFetchCount = 8; + const homeProjectFetchCount = 8; + const homeVisiblePostsCount = 8; + const homeVisibleProjectsCount = 8; + + useEffect(() => { + if (motion.prefersReducedMotion) { + heroProgress.value = 1; + streamsProgress.value = 1; + postsProgress.value = 1; + return; + } + heroProgress.value = withDelay(0, withSpring(1, hyprMotion.spring)); + streamsProgress.value = withDelay( + hyprMotion.staggerMs, + withSpring(1, hyprMotion.spring), + ); + postsProgress.value = withDelay( + hyprMotion.staggerMs * 2, + withSpring(1, hyprMotion.spring), + ); + }, [ + heroProgress, + hyprMotion.spring, + hyprMotion.staggerMs, + motion.prefersReducedMotion, + postsProgress, + streamsProgress, + ]); + + useEffect(() => { + if (motion.prefersReducedMotion) { + cursorOpacity.value = 1; + return; + } + + cursorOpacity.value = withRepeat( + withTiming(0.28, { + duration: hyprMotion.pulseDuration, + easing: ReanimatedEasing.inOut(ReanimatedEasing.quad), + }), + -1, + true, + ); + return () => { + cursorOpacity.value = 1; + }; + }, [cursorOpacity, hyprMotion.pulseDuration, motion.prefersReducedMotion]); + + useEffect(() => { + if (!rootNavigationState?.key) { + return; + } + + const task = InteractionManager.runAfterInteractions(() => { + router.prefetch("/streams"); + router.prefetch("/bytes"); + router.prefetch("/terminal"); + }); + + return () => { + task.cancel?.(); + }; + }, [rootNavigationState?.key, router]); + + const loadFeed = useCallback( + async (showLoader = true) => { + const requestId = requestGuard.beginRequest(); + try { + if (showLoader && requestGuard.isMounted()) { + setIsLoading(true); + } + const [postFeedRaw, projectFeedRaw, builderProjectsRaw] = + await Promise.all([ + getPostsFeed("time", 0, homePostFetchCount), + getProjectsFeed("time", 0, homeProjectFetchCount), + user?.id ? getProjectsByBuilderId(user.id) : Promise.resolve([]), + ]); + + const postFeed = Array.isArray(postFeedRaw) ? postFeedRaw : []; + const projectFeed = Array.isArray(projectFeedRaw) ? projectFeedRaw : []; + const builderProjects = Array.isArray(builderProjectsRaw) + ? builderProjectsRaw + : []; + + const selectedPosts = postFeed.slice(0, homeVisiblePostsCount); + + const projectMap = new Map( + projectFeed.map((project) => [project.id, project]), + ); + builderProjects.forEach((project) => { + projectMap.set(project.id, project); + }); + const combinedProjects = Array.from(projectMap.values()).slice( + 0, + homeVisibleProjectsCount, + ); + const builderCounts = await Promise.all( + combinedProjects.map((project) => + getProjectBuilders(project.id).catch(() => []), + ), + ); + const uiProjects = combinedProjects.map((project, index) => + mapProjectToUi(project, builderCounts[index]?.length ?? 0), + ); + + const uiPosts = await Promise.all( + selectedPosts.map(async (post) => { + const [postUser, project] = await Promise.all([ + getUserById(post.user).catch(() => null), + projectMap.get(post.project) + ? Promise.resolve(projectMap.get(post.project)!) + : Promise.resolve(null), + ]); + return mapPostToUi(post, postUser, project); + }), + ); + + const tagCounts = new Map(); + uiProjects.forEach((project) => + project.tags.forEach((tag) => + tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1), + ), + ); + uiPosts.forEach((post) => + post.tags.forEach((tag) => + tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1), + ), + ); + + if (!requestGuard.isActive(requestId)) { + return; + } + + setProjects(uiProjects); + setPosts(uiPosts); + setBuilderProjectIds(builderProjects.map((project) => project.id)); + setTags( + Array.from(tagCounts.entries()) + .sort((a, b) => b[1] - a[1]) + .map(([tag]) => tag), + ); + setHasError(false); + } catch { + if (!requestGuard.isActive(requestId)) { + return; + } + setProjects([]); + setPosts([]); + setTags([]); + setHasError(true); + } finally { + if (showLoader && requestGuard.isMounted()) { + setIsLoading(false); + } + } + }, + [ + homePostFetchCount, + homeProjectFetchCount, + homeVisiblePostsCount, + homeVisibleProjectsCount, + requestGuard, + user?.id, + ], + ); + + useEffect(() => { + loadFeed(); + }, [loadFeed]); + + useEffect(() => { + return subscribeToPostEvents((event) => { + setPosts((prev) => { + if (event.type === "updated") { + return prev.map((post) => + post.id === event.postId + ? { + ...post, + content: event.content, + media: event.media ?? post.media, + } + : post, + ); + } + if (event.type === "stats") { + return prev.map((post) => + post.id === event.postId + ? { + ...post, + likes: event.likes ?? post.likes, + comments: event.comments ?? post.comments, + } + : post, + ); + } + if (event.type === "deleted") { + return prev.filter((post) => post.id !== event.postId); + } + return prev; + }); + }); + }, []); + + useEffect(() => { + return subscribeToProjectEvents((event) => { + setProjects((prev) => applyProjectEvent(prev, event)); + }); + }, []); + + useFocusEffect( + useCallback(() => { + if (!hasFocusedRef.current) { + hasFocusedRef.current = true; + return; + } + + const task = InteractionManager.runAfterInteractions(() => { + beginFreshReadWindow(); + void loadFeed(false); + }); + + return () => { + task.cancel?.(); + }; + }, [loadFeed]), + ); + + const handleRefresh = useCallback(async () => { + setIsRefreshing(true); + beginFreshReadWindow(); + await loadFeed(false); + setIsRefreshing(false); + }, [loadFeed]); + + useAutoRefresh(() => loadFeed(false), { focusRefresh: false }); + + const heroRevealStyle = useAnimatedStyle(() => ({ + opacity: heroProgress.value, + transform: [ + { translateY: (1 - heroProgress.value) * 22 }, + { scale: 0.97 + heroProgress.value * 0.03 }, + ], + })); + + const streamsRevealStyle = useAnimatedStyle(() => ({ + opacity: streamsProgress.value, + transform: [ + { translateY: (1 - streamsProgress.value) * 22 }, + { scale: 0.97 + streamsProgress.value * 0.03 }, + ], + })); + + const postsRevealStyle = useAnimatedStyle(() => ({ + opacity: postsProgress.value, + transform: [ + { translateY: (1 - postsProgress.value) * 22 }, + { scale: 0.97 + postsProgress.value * 0.03 }, + ], + })); + + const cursorStyle = useAnimatedStyle(() => ({ + opacity: cursorOpacity.value, + })); + return ( - - - - - - - - - - - - - + + + + + } + contentContainerStyle={[ + styles.scrollContainer, + { paddingTop: 8, paddingBottom: 96 + insets.bottom }, + ]} + > + + + + + + TODAY'S SHIP LOG + + + + + + $ feed.sync --today + + + + + + + Streams + + + {projects.length} + + + + + Bytes + + {posts.length} + + + + Tags + + {tags.length} + + + + + + + router.push("/streams")} + /> + {isLoading ? ( + + + + ) : projects.length ? ( + + String(project.id)} + renderItem={(project) => ( + + + + )} + /> + + ) : ( + + + {hasError + ? "Streams unavailable. Check the API and try again." + : "No streams yet."} + + + )} + + + + router.push("/bytes")} + /> + {isLoading ? ( + + + + ) : posts.length ? ( + + String(post.id)} + renderItem={(post) => ( + + + + )} + /> + + ) : ( + + + {hasError + ? "Feed unavailable. Check the API and try again." + : "No posts yet. Be the first to ship."} + + + )} + + + + + + (scrollRef.current as any) + ?.getNode?.() + .scrollTo({ y: 0, animated: true }) + } + bottomOffset={insets.bottom + 20} + /> - + ); } + const styles = StyleSheet.create({ - header: { - position: "absolute", - zIndex: 1, - width: "100%", - }, - filterContainer: { - position: "absolute", - alignSelf: "flex-end", - padding: 20, - paddingTop: 140, - zIndex: 1, + screen: { + flex: 1, + }, + safeArea: { + flex: 1, + }, + background: { + ...StyleSheet.absoluteFillObject, }, scrollContainer: { - paddingTop: 150, - }, - scrolltotopbutton: { - alignSelf: "flex-start", - position:"absolute", - paddingTop:60, - zIndex:2, - paddingLeft:20 + gap: 20, + paddingHorizontal: 16, + paddingTop: 0, + }, + edgeToEdgeRail: { + marginHorizontal: -16, + }, + loadingState: { + alignItems: "center", + gap: 8, + paddingVertical: 24, + }, + emptyState: { + alignItems: "center", + paddingVertical: 24, + }, + heroCard: { + borderRadius: 14, + paddingHorizontal: 10, + paddingVertical: 8, + gap: 7, + borderWidth: 1, + }, + shipTopRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + }, + shipDot: { + width: 7, + height: 7, + borderRadius: 99, + }, + shipCommandRow: { + flexDirection: "row", + alignItems: "center", + gap: 6, + }, + shipCursor: { + width: 8, + height: 2, + borderRadius: 1, + }, + shipMiniGrid: { + flexDirection: "row", + gap: 8, + }, + shipMiniCard: { + flex: 1, + minHeight: 48, + borderRadius: 10, + borderWidth: 1, + paddingHorizontal: 8, + paddingVertical: 6, + justifyContent: "center", + }, + carouselRow: { + flexDirection: "row", + gap: 12, + paddingVertical: 4, + paddingHorizontal: 16, + }, + carouselCard: { + borderRadius: 14, + height: 110, + width: 240, + borderWidth: 1, + opacity: 0.7, + }, + carouselCardWrap: { + width: 260, + }, + carouselPost: { + borderRadius: 14, + height: 140, + width: 280, + borderWidth: 1, + opacity: 0.7, + }, + carouselPostWrap: { + width: 300, + }, + skeletonStack: { + gap: 12, + }, + skeletonCard: { + borderRadius: 14, + height: 96, + borderWidth: 1, + opacity: 0.7, + }, + skeletonChip: { + height: 22, + width: 70, + borderRadius: 8, + opacity: 0.7, + }, + tagCloud: { + flexDirection: "row", + flexWrap: "wrap", + gap: 8, }, }); diff --git a/frontend/app/(tabs)/profile.tsx b/frontend/app/(tabs)/profile.tsx index 731de17..6f948f5 100644 --- a/frontend/app/(tabs)/profile.tsx +++ b/frontend/app/(tabs)/profile.tsx @@ -1,35 +1,1165 @@ -import TopBar from "@/components/ui/TopBar"; +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { + Animated, + InteractionManager, + Modal, + Pressable, + RefreshControl, + ScrollView, + StyleSheet, + TextInput, + View, +} from "react-native"; +import { + SafeAreaView, + useSafeAreaInsets, +} from "react-native-safe-area-context"; +import { useRouter } from "expo-router"; +import { useFocusEffect } from "@react-navigation/native"; +import { Feather } from "@expo/vector-icons"; +import { ApiUser, UserProps } from "@/constants/Types"; +import { ProjectCard } from "@/components/ProjectCard"; +import { InfiniteHorizontalCycle } from "@/components/InfiniteHorizontalCycle"; +import { SectionHeader } from "@/components/SectionHeader"; +import { StatPill } from "@/components/StatPill"; +import { ThemedText } from "@/components/ThemedText"; +import { UserCard } from "@/components/UserCard"; +import { Post } from "@/components/Post"; import User from "@/components/User"; -import { UserProps } from "@/constants/Types"; -import React from "react"; - -const username = "dev_user1"; +import { FloatingScrollTopButton } from "@/components/FloatingScrollTopButton"; +import { TopBlur } from "@/components/TopBlur"; +import { UnifiedLoadingList } from "@/components/UnifiedLoading"; +import { useAutoRefresh } from "@/hooks/useAutoRefresh"; +import { useAppColors } from "@/hooks/useAppColors"; +import { useMotionConfig } from "@/hooks/useMotionConfig"; +import { useTopBlurScroll } from "@/hooks/useTopBlurScroll"; +import { useAuth } from "@/contexts/AuthContext"; +import { + beginFreshReadWindow, + getPostById, + getPostsByUserId, + getProjectById, + getProjectBuilders, + getProjectsByBuilderId, + getUserById, + getUserByUsername, + getUsersFollowers, + getUsersFollowersUsernames, + getUsersFollowing, + getUsersFollowingUsernames, + followUser, + unfollowUser, +} from "@/services/api"; +import { mapPostToUi, mapProjectToUi } from "@/services/mappers"; +import { useSaved } from "@/contexts/SavedContext"; +import { useSavedStreams } from "@/contexts/SavedStreamsContext"; +import { subscribeToPostEvents } from "@/services/postEvents"; +import { + applyProjectEvent, + subscribeToProjectEvents, +} from "@/services/projectEvents"; export default function ProfileScreen() { - const [currentUser, setCurrentUser] = React.useState(null); + const colors = useAppColors(); + const insets = useSafeAreaInsets(); + const router = useRouter(); + const { user: authUser } = useAuth(); + const { savedPostIds } = useSaved(); + const { savedProjectIds, removeSavedProjectIds } = useSavedStreams(); + const [currentUser, setCurrentUser] = useState(null); + const [projects, setProjects] = useState( + [] as ReturnType[], + ); + const [posts, setPosts] = useState([] as ReturnType[]); + const [savedPosts, setSavedPosts] = useState( + [] as ReturnType[], + ); + const [savedStreams, setSavedStreams] = useState( + [] as ReturnType[], + ); + const [builderProjectIds, setBuilderProjectIds] = useState([]); + const [isSavedLoading, setIsSavedLoading] = useState(false); + const [isSavedStreamsLoading, setIsSavedStreamsLoading] = useState(false); + const [followersCount, setFollowersCount] = useState(0); + const [followingCount, setFollowingCount] = useState(0); + const [streakCount, setStreakCount] = useState(0); + const [shipsCount, setShipsCount] = useState(0); + const [isLoading, setIsLoading] = useState(true); + const [hasError, setHasError] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + const [isFollowersOpen, setIsFollowersOpen] = useState(false); + const [isFollowingOpen, setIsFollowingOpen] = useState(false); + const [isFollowersLoading, setIsFollowersLoading] = useState(false); + const [isFollowingLoading, setIsFollowingLoading] = useState(false); + const [followerUsers, setFollowerUsers] = useState([]); + const [followingUsers, setFollowingUsers] = useState([]); + const [followingSet, setFollowingSet] = useState>(new Set()); + const [isFollowingBusy, setIsFollowingBusy] = useState(false); + const [followersQuery, setFollowersQuery] = useState(""); + const [followingQuery, setFollowingQuery] = useState(""); + const [visibleProjectCount, setVisibleProjectCount] = useState(0); + const [visibleSavedStreamCount, setVisibleSavedStreamCount] = useState(0); + const [visiblePostCount, setVisiblePostCount] = useState(0); + const [visibleSavedPostCount, setVisibleSavedPostCount] = useState(0); + const motion = useMotionConfig(); + const reveal = useRef(new Animated.Value(0.08)).current; + const hasLoadedRef = useRef(false); + const hasFocusedRef = useRef(false); + const scrollRef = useRef(null); + const { scrollY, onScroll } = useTopBlurScroll(); - React.useEffect(() => { - async function fetchUser() { - const response = await fetch(`http://localhost:8080/users/${username}`); - const userData = await response.json(); - console.log(userData); - setCurrentUser(userData); + const filteredFollowerUsers = React.useMemo(() => { + const trimmed = followersQuery.trim().toLowerCase(); + if (!trimmed) { + return followerUsers; } + return followerUsers.filter((user) => + user.username.toLowerCase().includes(trimmed), + ); + }, [followerUsers, followersQuery]); + + const filteredFollowingUsers = React.useMemo(() => { + const trimmed = followingQuery.trim().toLowerCase(); + if (!trimmed) { + return followingUsers; + } + return followingUsers.filter((user) => + user.username.toLowerCase().includes(trimmed), + ); + }, [followingUsers, followingQuery]); + + const scheduleProgressiveCount = useCallback( + ( + total: number, + setCount: React.Dispatch>, + config: { initial: number; step: number; delayMs: number }, + ) => { + let cancelled = false; + let timer: ReturnType | null = null; + + const initialCount = Math.min(total, config.initial); + setCount(initialCount); + + const task = InteractionManager.runAfterInteractions(() => { + if (cancelled || initialCount >= total) { + return; + } + + const advance = () => { + if (cancelled) { + return; + } + setCount((prev) => { + const next = Math.min(total, prev + config.step); + if (next < total) { + timer = setTimeout(advance, config.delayMs); + } + return next; + }); + }; + + timer = setTimeout(advance, config.delayMs); + }); + + return () => { + cancelled = true; + if (timer) { + clearTimeout(timer); + timer = null; + } + task.cancel?.(); + }; + }, + [], + ); + + useEffect(() => { + return scheduleProgressiveCount(projects.length, setVisibleProjectCount, { + initial: 4, + step: 2, + delayMs: 56, + }); + }, [projects.length, scheduleProgressiveCount]); + + useEffect(() => { + return scheduleProgressiveCount( + savedStreams.length, + setVisibleSavedStreamCount, + { + initial: 4, + step: 2, + delayMs: 56, + }, + ); + }, [savedStreams.length, scheduleProgressiveCount]); + + useEffect(() => { + return scheduleProgressiveCount(posts.length, setVisiblePostCount, { + initial: 2, + step: 1, + delayMs: 72, + }); + }, [posts.length, scheduleProgressiveCount]); + + useEffect(() => { + return scheduleProgressiveCount( + savedPosts.length, + setVisibleSavedPostCount, + { + initial: 2, + step: 1, + delayMs: 72, + }, + ); + }, [savedPosts.length, scheduleProgressiveCount]); + + const visibleProjects = React.useMemo( + () => projects.slice(0, visibleProjectCount), + [projects, visibleProjectCount], + ); + + const visibleSavedStreams = React.useMemo( + () => savedStreams.slice(0, visibleSavedStreamCount), + [savedStreams, visibleSavedStreamCount], + ); + + const visiblePosts = React.useMemo( + () => posts.slice(0, visiblePostCount), + [posts, visiblePostCount], + ); + + const visibleSavedPosts = React.useMemo( + () => savedPosts.slice(0, visibleSavedPostCount), + [savedPosts, visibleSavedPostCount], + ); + + useEffect(() => { + if (motion.prefersReducedMotion) { + reveal.setValue(1); + return; + } + + Animated.spring(reveal, { + toValue: 1, + speed: 16, + bounciness: 7, + useNativeDriver: true, + }).start(); + }, [motion, reveal]); + + const fetchUser = useCallback( + async (options?: { silent?: boolean }) => { + const isSilent = options?.silent; + const shouldShowLoader = !isSilent && !hasLoadedRef.current; + try { + if (shouldShowLoader) { + setIsLoading(true); + } + if (!authUser?.username || !authUser.id) { + setCurrentUser(null); + setProjects([]); + setPosts([]); + return; + } + let resolvedUser = authUser as UserProps; + try { + const userData = await getUserByUsername(authUser.username); + if (userData) { + resolvedUser = userData; + } + setHasError(false); + } catch { + setHasError(true); + } + + setCurrentUser(resolvedUser); + + if (resolvedUser?.id) { + try { + const [userProjects, userPosts, followers, following] = + await Promise.all([ + getProjectsByBuilderId(resolvedUser.id), + getPostsByUserId(resolvedUser.id), + getUsersFollowers(resolvedUser.username), + getUsersFollowing(resolvedUser.username), + ]); + + const safeProjects = Array.isArray(userProjects) + ? userProjects + : []; + const safePosts = Array.isArray(userPosts) ? userPosts : []; + const safeFollowers = Array.isArray(followers) ? followers : []; + const safeFollowing = Array.isArray(following) ? following : []; + + const projectMap = new Map( + safeProjects.map((project) => [project.id, project]), + ); + + const builderCounts = await Promise.all( + safeProjects.map((project) => + getProjectBuilders(project.id).catch(() => []), + ), + ); + setProjects( + safeProjects.map((project, index) => + mapProjectToUi(project, builderCounts[index]?.length ?? 0), + ), + ); + setBuilderProjectIds(safeProjects.map((project) => project.id)); + setShipsCount(safePosts.length); + setFollowersCount(safeFollowers.length); + setFollowingCount(safeFollowing.length); + const uniqueDays = Array.from( + new Set( + safePosts.map((post) => + new Date(post.created_on).toISOString().slice(0, 10), + ), + ), + ).sort((a, b) => (a > b ? -1 : 1)); + let streak = 0; + let cursor = new Date(); + for (const day of uniqueDays) { + const dayDate = new Date(day + "T00:00:00Z"); + if ( + cursor.toISOString().slice(0, 10) === + dayDate.toISOString().slice(0, 10) + ) { + streak += 1; + cursor.setUTCDate(cursor.getUTCDate() - 1); + } else { + break; + } + } + setStreakCount(streak); + setPosts( + safePosts + .slice(0, 2) + .map((post) => + mapPostToUi( + post, + { ...resolvedUser, id: resolvedUser.id! }, + projectMap.get(post.project) ?? null, + ), + ), + ); + } catch { + if (!isSilent) { + setProjects([]); + setPosts([]); + setShipsCount(0); + setFollowersCount(0); + setFollowingCount(0); + setStreakCount(0); + setBuilderProjectIds([]); + } + setHasError(true); + } + } + } catch { + if (!isSilent) { + setCurrentUser(null); + setProjects([]); + setPosts([]); + setBuilderProjectIds([]); + } + setHasError(true); + } finally { + hasLoadedRef.current = true; + if (shouldShowLoader) { + setIsLoading(false); + } + } + }, + [authUser], + ); + + const loadFollowers = useCallback(async () => { + if (!authUser?.username) { + return; + } + setIsFollowersLoading(true); + try { + const list = await getUsersFollowersUsernames(authUser.username); + const names = Array.isArray(list) ? list : []; + const users = await Promise.all( + names.map((name) => getUserByUsername(name).catch(() => null)), + ); + setFollowerUsers(users.filter((item): item is ApiUser => item !== null)); + const followingIds = await getUsersFollowing(authUser.username).catch( + () => [], + ); + setFollowingSet(new Set(Array.isArray(followingIds) ? followingIds : [])); + } finally { + setIsFollowersLoading(false); + } + }, [authUser?.username]); + + const loadFollowing = useCallback(async () => { + if (!authUser?.username) { + return; + } + setIsFollowingLoading(true); + try { + const list = await getUsersFollowingUsernames(authUser.username); + const names = Array.isArray(list) ? list : []; + const users = await Promise.all( + names.map((name) => getUserByUsername(name).catch(() => null)), + ); + setFollowingUsers(users.filter((item): item is ApiUser => item !== null)); + const followingIds = await getUsersFollowing(authUser.username).catch( + () => [], + ); + setFollowingSet(new Set(Array.isArray(followingIds) ? followingIds : [])); + } finally { + setIsFollowingLoading(false); + } + }, [authUser?.username]); + + const handleToggleFollow = async (target: ApiUser) => { + if (!authUser?.username || isFollowingBusy) { + return; + } + const targetId = target.id ?? -1; + const isFollowing = followingSet.has(targetId); + setIsFollowingBusy(true); + try { + if (isFollowing) { + await unfollowUser(authUser.username, target.username); + } else { + await followUser(authUser.username, target.username); + } + setFollowingSet((prev) => { + const next = new Set(prev); + if (isFollowing) { + next.delete(targetId); + } else if (targetId > 0) { + next.add(targetId); + } + return next; + }); + } finally { + setIsFollowingBusy(false); + } + }; + + useEffect(() => { fetchUser(); + }, [fetchUser]); + + const loadSaved = useCallback( + async (showLoader = true) => { + if (!savedPostIds.length) { + setSavedPosts([]); + return; + } + if (showLoader) { + setIsSavedLoading(true); + } + try { + const savedData = await Promise.all( + savedPostIds.map(async (postId) => { + try { + const post = await getPostById(postId); + const [user, project] = await Promise.all([ + getUserById(post.user).catch(() => null), + getProjectById(post.project).catch(() => null), + ]); + return mapPostToUi(post, user, project); + } catch { + return null; + } + }), + ); + + setSavedPosts( + savedData.filter( + (post): post is ReturnType => post !== null, + ), + ); + } catch { + if (showLoader) { + setSavedPosts([]); + } + } finally { + if (showLoader) { + setIsSavedLoading(false); + } + } + }, + [savedPostIds], + ); + + const loadSavedStreams = useCallback( + async (showLoader = true) => { + if (!savedProjectIds.length) { + setSavedStreams([]); + if (showLoader) { + setIsSavedStreamsLoading(false); + } + return; + } + if (showLoader) { + setIsSavedStreamsLoading(true); + } + try { + const projectsData = await Promise.all( + savedProjectIds.map((projectId) => + getProjectById(projectId).catch(() => null), + ), + ); + const missingIds = savedProjectIds.filter( + (_, index) => !projectsData[index], + ); + if (missingIds.length) { + void removeSavedProjectIds(missingIds); + } + const validProjects = projectsData.filter((project) => project); + const builderCounts = await Promise.all( + validProjects.map((project) => + getProjectBuilders(project!.id).catch(() => []), + ), + ); + const mapped = validProjects.map((project, index) => + mapProjectToUi(project!, builderCounts[index]?.length ?? 0), + ); + setSavedStreams(mapped); + } catch { + setSavedStreams([]); + } finally { + if (showLoader) { + setIsSavedStreamsLoading(false); + } + } + }, + [removeSavedProjectIds, savedProjectIds], + ); + + useEffect(() => { + loadSaved(); + }, [loadSaved]); + + useEffect(() => { + loadSavedStreams(); + }, [loadSavedStreams]); + + useEffect(() => { + return subscribeToPostEvents((event) => { + const applyUpdate = (prev: ReturnType[]) => { + if (event.type === "updated") { + return prev.map((post) => + post.id === event.postId + ? { + ...post, + content: event.content, + media: event.media ?? post.media, + } + : post, + ); + } + if (event.type === "stats") { + return prev.map((post) => + post.id === event.postId + ? { + ...post, + likes: event.likes ?? post.likes, + comments: event.comments ?? post.comments, + } + : post, + ); + } + if (event.type === "deleted") { + return prev.filter((post) => post.id !== event.postId); + } + return prev; + }; + + setPosts(applyUpdate); + setSavedPosts(applyUpdate); + }); }, []); + useEffect(() => { + return subscribeToProjectEvents((event) => { + setProjects((prev) => applyProjectEvent(prev, event)); + setSavedStreams((prev) => applyProjectEvent(prev, event)); + }); + }, []); + + useFocusEffect( + useCallback(() => { + if (!hasFocusedRef.current) { + hasFocusedRef.current = true; + return; + } + + const task = InteractionManager.runAfterInteractions(() => { + beginFreshReadWindow(); + void fetchUser({ silent: true }); + void loadSaved(false); + void loadSavedStreams(false); + }); + + return () => { + task.cancel?.(); + }; + }, [fetchUser, loadSaved, loadSavedStreams]), + ); + + const refreshProfile = useCallback(async () => { + await Promise.all([ + fetchUser({ silent: true }), + loadSaved(false), + loadSavedStreams(false), + ]); + }, [fetchUser, loadSaved, loadSavedStreams]); + + const handleRefresh = useCallback(async () => { + setIsRefreshing(true); + beginFreshReadWindow(); + await refreshProfile(); + setIsRefreshing(false); + }, [refreshProfile]); + + useAutoRefresh(refreshProfile, { focusRefresh: false }); + return ( - <> - {/* */} - {currentUser && ( - - )} - + + + + + } + contentContainerStyle={[ + styles.container, + { paddingTop: 8, paddingBottom: 96 + insets.bottom }, + ]} + > + + + + + Profile + + + Hello World! + + + [ + styles.headerSettingsButton, + { + borderColor: colors.tint, + backgroundColor: colors.surfaceAlt, + shadowColor: colors.tint, + opacity: pressed ? 0.82 : 1, + }, + ]} + onPress={() => router.push("/settings")} + > + + + + + + + {isLoading ? ( + + + + + + ) : currentUser ? ( + <> + + + + { + setIsFollowersOpen(true); + loadFollowers(); + }} + > + + + { + setIsFollowingOpen(true); + loadFollowing(); + }} + > + + + + + + ) : ( + + + {hasError + ? "Profile unavailable. Check the API and try again." + : "Profile not found."} + + + )} + + + + router.push("/manage-streams")} + /> + + {isLoading ? ( + + ) : projects.length ? ( + + String(project.id)} + renderItem={(project) => ( + + + + )} + /> + + ) : ( + + + No projects yet. + + + )} + + + + + router.push("/saved-streams")} + /> + + {isSavedStreamsLoading ? ( + + ) : savedStreams.length ? ( + + String(project.id)} + renderItem={(project) => ( + + { + if (!nextSaved) { + setSavedStreams((prev) => + prev.filter((item) => item.id !== project.id), + ); + } + }} + /> + + )} + /> + + ) : ( + + + No saved streams yet. + + + )} + + + + + router.push("/archive-bytes")} + /> + + {isLoading ? ( + + ) : posts.length ? ( + visiblePosts.map((post) => ) + ) : ( + + + No recent posts yet. + + + )} + + + + + router.push("/saved-library")} + /> + + {isSavedLoading ? ( + + ) : savedPosts.length ? ( + visibleSavedPosts.map((post) => ( + + )) + ) : ( + + + No saved bytes yet. + + + )} + + + + + + scrollRef.current?.scrollTo({ y: 0, animated: true })} + bottomOffset={insets.bottom + 20} + /> + setIsFollowersOpen(false)} + > + setIsFollowersOpen(false)} + > + + Followers + + + {isFollowersLoading ? ( + + Loading... + + ) : filteredFollowerUsers.length ? ( + filteredFollowerUsers.map((item) => ( + { + setIsFollowersOpen(false); + router.push({ + pathname: "/user/[username]", + params: { username: item.username }, + }); + }} + onToggleFollow={() => handleToggleFollow(item)} + /> + )) + ) : followersQuery.trim() ? ( + + No matches found. + + ) : ( + + No followers yet. + + )} + + + + + setIsFollowingOpen(false)} + > + setIsFollowingOpen(false)} + > + + Following + + + {isFollowingLoading ? ( + + Loading... + + ) : filteredFollowingUsers.length ? ( + filteredFollowingUsers.map((item) => ( + { + setIsFollowingOpen(false); + router.push({ + pathname: "/user/[username]", + params: { username: item.username }, + }); + }} + onToggleFollow={() => handleToggleFollow(item)} + /> + )) + ) : followingQuery.trim() ? ( + + No matches found. + + ) : ( + + Not following anyone yet. + + )} + + + + + ); } + +const styles = StyleSheet.create({ + screen: { + flex: 1, + }, + safeArea: { + flex: 1, + }, + background: { + ...StyleSheet.absoluteFillObject, + }, + container: { + paddingVertical: 16, + paddingHorizontal: 16, + gap: 20, + paddingTop: 0, + }, + edgeToEdgeRail: { + marginHorizontal: -16, + overflow: "visible", + }, + title: { + fontSize: 26, + lineHeight: 30, + }, + headerRow: { + flexDirection: "row", + alignItems: "flex-start", + justifyContent: "space-between", + gap: 12, + }, + headerTextWrap: { + flex: 1, + gap: 2, + }, + headerSettingsButton: { + borderWidth: 1, + borderRadius: 10, + width: 34, + height: 34, + alignItems: "center", + justifyContent: "center", + shadowOpacity: 0.2, + shadowRadius: 6, + shadowOffset: { width: 0, height: 0 }, + elevation: 2, + marginRight: 24, + }, + profileCard: { + borderRadius: 16, + padding: 14, + borderWidth: 1, + gap: 16, + }, + sectionBlock: { + paddingTop: 0, + minHeight: 180, + }, + streamSectionBody: { + minHeight: 190, + }, + postSectionBody: { + minHeight: 210, + }, + skeletonStack: { + gap: 12, + }, + skeletonCard: { + borderRadius: 14, + height: 96, + borderWidth: 1, + opacity: 0.7, + }, + skeletonAvatar: { + width: 64, + height: 64, + borderRadius: 32, + borderWidth: 1, + opacity: 0.7, + }, + skeletonLine: { + height: 16, + borderRadius: 8, + borderWidth: 1, + opacity: 0.7, + }, + skeletonLineShort: { + height: 12, + width: 140, + borderRadius: 6, + borderWidth: 1, + opacity: 0.7, + }, + emptyState: { + alignItems: "center", + paddingVertical: 12, + }, + statRow: { + flexDirection: "row", + flexWrap: "wrap", + gap: 10, + }, + deleteAccountButton: { + alignSelf: "flex-start", + borderWidth: 1, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 8, + }, + projectCardSlot: { + width: 246, + minHeight: 186, + }, + modalBackdrop: { + flex: 1, + backgroundColor: "rgba(0, 0, 0, 0.6)", + alignItems: "center", + justifyContent: "center", + padding: 24, + }, + modalCard: { + width: "100%", + maxHeight: "70%", + borderRadius: 16, + padding: 16, + gap: 12, + }, + modalRow: { + paddingVertical: 8, + }, + searchInput: { + borderWidth: 1, + borderRadius: 12, + paddingHorizontal: 12, + paddingVertical: 8, + fontSize: 14, + }, +}); diff --git a/frontend/app/+not-found.tsx b/frontend/app/+not-found.tsx index 71c56ce..2d64ff8 100644 --- a/frontend/app/+not-found.tsx +++ b/frontend/app/+not-found.tsx @@ -1,15 +1,15 @@ -import React from 'react'; -import { Link, Stack } from 'expo-router'; -import { StyleSheet } from 'react-native'; -import { ThemedText } from '@/components/ThemedText'; -import { ThemedView } from '@/components/ThemedView'; +import React from "react"; +import { Link, Stack } from "expo-router"; +import { StyleSheet } from "react-native"; +import { ThemedText } from "@/components/ThemedText"; +import { ThemedView } from "@/components/ThemedView"; export default function NotFoundScreen() { return ( <> - + - This screen doesn't exist. + This screen does not exist. Go to home screen! @@ -21,8 +21,8 @@ export default function NotFoundScreen() { const styles = StyleSheet.create({ container: { flex: 1, - alignItems: 'center', - justifyContent: 'center', + alignItems: "center", + justifyContent: "center", padding: 20, }, link: { diff --git a/frontend/app/_layout.tsx b/frontend/app/_layout.tsx index a7d804b..61603ce 100644 --- a/frontend/app/_layout.tsx +++ b/frontend/app/_layout.tsx @@ -1,25 +1,167 @@ import { DarkTheme, DefaultTheme, + type Theme, ThemeProvider, } from "@react-navigation/native"; import { useFonts } from "expo-font"; -import { Stack } from "expo-router"; +import { Stack, useRouter, useSegments } from "expo-router"; import * as SplashScreen from "expo-splash-screen"; +import Constants from "expo-constants"; import { StatusBar } from "expo-status-bar"; -import { useEffect } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { View } from "react-native"; +import { GestureHandlerRootView } from "react-native-gesture-handler"; import "react-native-reanimated"; -import { SafeAreaView, Platform } from "react-native"; +import { Colors } from "@/constants/Colors"; +import { AuthProvider, useAuth } from "@/contexts/AuthContext"; +import { SavedProvider } from "@/contexts/SavedContext"; +import { SavedStreamsProvider } from "@/contexts/SavedStreamsContext"; +import { + PreferencesProvider, + usePreferences, +} from "@/contexts/PreferencesContext"; +import { + NotificationsProvider, + useNotifications, +} from "@/contexts/NotificationsContext"; import { useColorScheme } from "@/hooks/useColorScheme"; -import { ThemedView } from "@/components/ThemedView"; +import { HyprBackdrop } from "@/components/HyprBackdrop"; +import { BootScreen } from "@/components/BootScreen"; +import { InAppNotificationBanner } from "@/components/InAppNotificationBanner"; // Prevent the splash screen from auto-hiding before asset loading is complete SplashScreen.preventAutoHideAsync(); +type NotificationsModule = typeof import("expo-notifications"); + +const isExpoGoRuntime = () => { + const ownership = Constants.appOwnership; + const executionEnvironment = Constants.executionEnvironment; + return ownership === "expo" || executionEnvironment === "storeClient"; +}; + +let hasShownBoot = __DEV__; + +const navLightTheme: Theme = { + ...DefaultTheme, + colors: { + ...DefaultTheme.colors, + primary: Colors.light.tint, + background: Colors.light.background, + card: Colors.light.surface, + text: Colors.light.text, + border: Colors.light.border, + notification: Colors.light.tint, + }, +}; + +const navDarkTheme: Theme = { + ...DarkTheme, + colors: { + ...DarkTheme.colors, + primary: Colors.dark.tint, + background: Colors.dark.background, + card: Colors.dark.surface, + text: Colors.dark.text, + border: Colors.dark.border, + notification: Colors.dark.tint, + }, +}; + export default function RootLayout() { + return ( + + + + + + + + + + + + + + ); +} + +function RootLayoutNav() { const colorScheme = useColorScheme(); const [loaded] = useFonts({ SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"), }); + const { user, isLoading, justSignedUp } = useAuth(); + const { preferences } = usePreferences(); + const { inAppBanner, dismissInAppBanner } = useNotifications(); + const segments = useSegments(); + const router = useRouter(); + const [showBoot, setShowBoot] = useState(() => !hasShownBoot); + const shouldShowBoot = useMemo( + () => loaded && showBoot && !hasShownBoot, + [loaded, showBoot], + ); + const stackAnimation = useMemo(() => { + switch (preferences.pageTransitionEffect) { + case "none": + return "none" as const; + case "default": + return "default" as const; + default: + return "fade" as const; + } + }, [preferences.pageTransitionEffect]); + + const openFromPayload = useCallback( + (payload: Record) => { + const type = String(payload?.type ?? "").toLowerCase(); + const actorName = String(payload?.actor_name ?? "").trim(); + const asNumber = (value: unknown) => { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; + }; + + if (type === "direct_message" && actorName) { + router.push({ pathname: "/terminal", params: { chat: actorName } }); + return; + } + + if (type === "follow_user" && actorName) { + router.push({ + pathname: "/user/[username]", + params: { username: actorName }, + }); + return; + } + + const projectId = asNumber(payload?.project_id); + if ((type === "save_project" || type === "builder_added") && projectId) { + router.push({ + pathname: "/stream/[projectId]", + params: { projectId: String(projectId) }, + }); + return; + } + + const postId = asNumber(payload?.post_id); + if ((type === "save_post" || type === "comment_post") && postId) { + router.push({ + pathname: "/post/[postId]", + params: { postId: String(postId) }, + }); + return; + } + + router.push("/notifications"); + }, + [router], + ); useEffect(() => { if (loaded) { @@ -28,18 +170,132 @@ export default function RootLayout() { } }, [loaded]); + useEffect(() => { + if (!loaded || isLoading) { + return; + } + + const inAuthGroup = segments[0] === "(auth)"; + const inWelcome = segments[0] === "welcome"; + + if (!user && !inAuthGroup) { + router.replace("/(auth)/sign-in"); + } + + if (user && justSignedUp && !preferences.hasSeenWelcomeTour && !inWelcome) { + router.replace({ pathname: "/welcome", params: { mode: "first-run" } }); + return; + } + + if (user && inAuthGroup) { + router.replace("/(tabs)"); + } + }, [ + isLoading, + justSignedUp, + loaded, + preferences.hasSeenWelcomeTour, + segments, + router, + user, + ]); + + useEffect(() => { + if (isExpoGoRuntime()) { + return; + } + + let cancelled = false; + let subscription: { remove: () => void } | null = null; + + const setup = async () => { + const Notifications: NotificationsModule = + await import("expo-notifications"); + if (cancelled) { + return; + } + + subscription = Notifications.addNotificationResponseReceivedListener( + (response) => { + const data = (response?.notification?.request?.content?.data ?? + {}) as Record; + openFromPayload(data); + }, + ); + + const response = await Notifications.getLastNotificationResponseAsync(); + if (cancelled) { + return; + } + + const data = (response?.notification?.request?.content?.data ?? + {}) as Record; + if (Object.keys(data).length) { + openFromPayload(data); + } + }; + + void setup(); + + return () => { + cancelled = true; + subscription?.remove(); + }; + }, [openFromPayload]); + // Render null if fonts are not loaded if (!loaded) { return null; } return ( - - - - - - + + + + + + + + + + + + + {shouldShowBoot ? ( + { + hasShownBoot = true; + setShowBoot(false); + }} + /> + ) : null} + { + const payload = + (inAppBanner?.payload as Record | undefined) ?? {}; + dismissInAppBanner(); + openFromPayload(payload); + }} + /> + + ); } diff --git a/frontend/app/archive-bytes.tsx b/frontend/app/archive-bytes.tsx new file mode 100644 index 0000000..01184f4 --- /dev/null +++ b/frontend/app/archive-bytes.tsx @@ -0,0 +1,243 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { + Animated, + RefreshControl, + ScrollView, + StyleSheet, + View, +} from "react-native"; +import { + SafeAreaView, + useSafeAreaInsets, +} from "react-native-safe-area-context"; +import { useFocusEffect } from "@react-navigation/native"; +import { Post } from "@/components/Post"; +import { FloatingScrollTopButton } from "@/components/FloatingScrollTopButton"; +import { ThemedText } from "@/components/ThemedText"; +import { TopBlur } from "@/components/TopBlur"; +import { UnifiedLoadingList } from "@/components/UnifiedLoading"; +import { useAutoRefresh } from "@/hooks/useAutoRefresh"; +import { useAppColors } from "@/hooks/useAppColors"; +import { useMotionConfig } from "@/hooks/useMotionConfig"; +import { useTopBlurScroll } from "@/hooks/useTopBlurScroll"; +import { useAuth } from "@/contexts/AuthContext"; +import { + clearApiCache, + getPostsByUserId, + getProjectById, + getUserById, +} from "@/services/api"; +import { mapPostToUi } from "@/services/mappers"; +import { subscribeToPostEvents } from "@/services/postEvents"; + +export default function ArchiveBytesScreen() { + const colors = useAppColors(); + const insets = useSafeAreaInsets(); + const { user } = useAuth(); + const [posts, setPosts] = useState([] as ReturnType[]); + const [isLoading, setIsLoading] = useState(true); + const [hasError, setHasError] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + const motion = useMotionConfig(); + const scrollRef = useRef(null); + const reveal = useRef(new Animated.Value(0.08)).current; + const { scrollY, onScroll } = useTopBlurScroll(); + + useEffect(() => { + if (motion.prefersReducedMotion) { + reveal.setValue(1); + return; + } + + Animated.timing(reveal, { + toValue: 1, + duration: motion.duration(420), + useNativeDriver: true, + }).start(); + }, [motion, reveal]); + + const loadArchive = useCallback( + async (showLoader = true) => { + if (!user?.id) { + if (showLoader) { + setIsLoading(false); + } + return; + } + try { + if (showLoader) { + setIsLoading(true); + } + const userPosts = await getPostsByUserId(user.id); + const projectMap = new Map< + number, + Awaited> | null + >(); + const mapped = await Promise.all( + userPosts.map(async (post) => { + const [postUser, postProject] = await Promise.all([ + getUserById(post.user).catch(() => null), + projectMap.get(post.project) + ? Promise.resolve(projectMap.get(post.project)!) + : getProjectById(post.project).catch(() => null), + ]); + projectMap.set(post.project, postProject); + return mapPostToUi(post, postUser, postProject); + }), + ); + + setPosts(mapped); + setHasError(false); + } catch { + setPosts([]); + setHasError(true); + } finally { + if (showLoader) { + setIsLoading(false); + } + } + }, + [user?.id], + ); + + useEffect(() => { + loadArchive(); + }, [loadArchive]); + + useEffect(() => { + return subscribeToPostEvents((event) => { + setPosts((prev) => { + if (event.type === "updated") { + return prev.map((post) => + post.id === event.postId + ? { + ...post, + content: event.content, + media: event.media ?? post.media, + } + : post, + ); + } + if (event.type === "stats") { + return prev.map((post) => + post.id === event.postId + ? { + ...post, + likes: event.likes ?? post.likes, + comments: event.comments ?? post.comments, + } + : post, + ); + } + if (event.type === "deleted") { + return prev.filter((post) => post.id !== event.postId); + } + return prev; + }); + }); + }, []); + + useFocusEffect( + useCallback(() => { + clearApiCache(); + loadArchive(false); + }, [loadArchive]), + ); + + const handleRefresh = useCallback(async () => { + setIsRefreshing(true); + clearApiCache(); + await loadArchive(false); + setIsRefreshing(false); + }, [loadArchive]); + useAutoRefresh(() => loadArchive(false), { focusRefresh: false }); + + return ( + + + + } + contentContainerStyle={[ + styles.container, + { paddingTop: 8, paddingBottom: 96 + insets.bottom }, + ]} + > + + + Byte archive + + + All your shipped updates in one place. + + + + {isLoading ? ( + + ) : posts.length ? ( + posts.map((post) => ) + ) : ( + + + {hasError ? "Bytes unavailable." : "No bytes yet."} + + + )} + + + + scrollRef.current?.scrollTo({ y: 0, animated: true })} + bottomOffset={insets.bottom + 20} + /> + + ); +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + }, + safeArea: { + flex: 1, + }, + container: { + paddingVertical: 16, + paddingHorizontal: 16, + gap: 16, + paddingTop: 0, + }, + title: { + fontSize: 26, + lineHeight: 30, + }, + emptyState: { + alignItems: "center", + paddingVertical: 24, + }, +}); diff --git a/frontend/app/bytes.tsx b/frontend/app/bytes.tsx new file mode 100644 index 0000000..8d7ba2f --- /dev/null +++ b/frontend/app/bytes.tsx @@ -0,0 +1,457 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { + Animated, + FlatList, + Pressable, + RefreshControl, + StyleSheet, + View, +} from "react-native"; +import { + SafeAreaView, + useSafeAreaInsets, +} from "react-native-safe-area-context"; +import { useFocusEffect } from "@react-navigation/native"; +import { + clearApiCache, + FeedSort, + getFollowingPostsFeed, + getPostsFeed, + getProjectById, + getSavedPostsFeed, + getUserById, +} from "@/services/api"; +import { mapPostToUi } from "@/services/mappers"; +import { Post } from "@/components/Post"; +import { FloatingScrollTopButton } from "@/components/FloatingScrollTopButton"; +import { ThemedText } from "@/components/ThemedText"; +import { TopBlur } from "@/components/TopBlur"; +import { + UnifiedLoadingInline, + UnifiedLoadingList, +} from "@/components/UnifiedLoading"; +import { useAutoRefresh } from "@/hooks/useAutoRefresh"; +import { useAppColors } from "@/hooks/useAppColors"; +import { useMotionConfig } from "@/hooks/useMotionConfig"; +import { useTopBlurScroll } from "@/hooks/useTopBlurScroll"; +import { useRequestGuard } from "@/hooks/useRequestGuard"; +import { subscribeToPostEvents } from "@/services/postEvents"; +import { useAuth } from "@/contexts/AuthContext"; + +export default function BytesScreen() { + const colors = useAppColors(); + const insets = useSafeAreaInsets(); + const { user } = useAuth(); + const [posts, setPosts] = useState([] as ReturnType[]); + const [isLoading, setIsLoading] = useState(true); + const [hasError, setHasError] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + const [activeFilter, setActiveFilter] = useState< + "all" | "following" | "saved" + >("all"); + const [activeSort, setActiveSort] = useState< + "recent" | "new" | "popular" | "hot" + >("recent"); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(true); + const [pageIndex, setPageIndex] = useState(0); + const motion = useMotionConfig(); + const requestGuard = useRequestGuard(); + const reveal = useRef(new Animated.Value(0.08)).current; + const listRef = useRef>>(null); + const { scrollY, onScroll } = useTopBlurScroll(); + const pageSize = 20; + + useEffect(() => { + if (motion.prefersReducedMotion) { + reveal.setValue(1); + return; + } + + Animated.timing(reveal, { + toValue: 1, + duration: motion.duration(420), + useNativeDriver: true, + }).start(); + }, [motion, reveal]); + + const loadBytes = useCallback( + async ({ + showLoader = true, + nextPage = 0, + append = false, + }: { + showLoader?: boolean; + nextPage?: number; + append?: boolean; + } = {}) => { + const requestId = requestGuard.beginRequest(); + try { + if (showLoader && requestGuard.isMounted()) { + setIsLoading(true); + } + const start = nextPage * pageSize; + const postFeedPromise = + activeFilter === "following" && user?.username + ? getFollowingPostsFeed(user.username, start, pageSize, activeSort) + : activeFilter === "saved" && user?.username + ? getSavedPostsFeed(user.username, start, pageSize, activeSort) + : getPostsFeed(activeSort as FeedSort, start, pageSize); + + let postFeedRaw: Awaited> = []; + try { + postFeedRaw = await postFeedPromise; + } catch { + if (activeFilter !== "all") { + postFeedRaw = await getPostsFeed( + activeSort as FeedSort, + start, + pageSize, + ); + if (requestGuard.isMounted()) { + setActiveFilter("all"); + } + } else { + throw new Error("Failed to load post feed"); + } + } + + const postFeed = Array.isArray(postFeedRaw) ? postFeedRaw : []; + + const uiPosts = await Promise.all( + postFeed.map(async (post) => { + const [user, project] = await Promise.all([ + getUserById(post.user).catch(() => null), + getProjectById(post.project).catch(() => null), + ]); + return mapPostToUi(post, user, project); + }), + ); + + if (!requestGuard.isActive(requestId)) { + return; + } + + setPosts((prev) => (append ? prev.concat(uiPosts) : uiPosts)); + setPageIndex(nextPage); + setHasMore(postFeed.length === pageSize); + setHasError(false); + } catch { + if (!requestGuard.isActive(requestId)) { + return; + } + if (!append) { + setPosts([]); + } + setHasError(true); + } finally { + if (showLoader && requestGuard.isMounted()) { + setIsLoading(false); + } + } + }, + [activeFilter, activeSort, requestGuard, user?.username], + ); + + useEffect(() => { + setHasMore(true); + setPageIndex(0); + loadBytes({ nextPage: 0 }); + }, [activeFilter, loadBytes]); + + useEffect(() => { + return subscribeToPostEvents((event) => { + setPosts((prev) => { + if (event.type === "updated") { + return prev.map((post) => + post.id === event.postId + ? { + ...post, + content: event.content, + media: event.media ?? post.media, + } + : post, + ); + } + if (event.type === "stats") { + return prev.map((post) => + post.id === event.postId + ? { + ...post, + likes: event.likes ?? post.likes, + comments: event.comments ?? post.comments, + } + : post, + ); + } + if (event.type === "deleted") { + return prev.filter((post) => post.id !== event.postId); + } + return prev; + }); + }); + }, []); + + useFocusEffect( + useCallback(() => { + clearApiCache(); + loadBytes({ showLoader: false, nextPage: 0 }); + }, [loadBytes]), + ); + + const handleRefresh = useCallback(async () => { + setIsRefreshing(true); + clearApiCache(); + setHasMore(true); + setPageIndex(0); + await loadBytes({ showLoader: false, nextPage: 0 }); + setIsRefreshing(false); + }, [loadBytes]); + + useAutoRefresh(() => loadBytes({ showLoader: false, nextPage: 0 }), { + focusRefresh: false, + }); + + const handleLoadMore = useCallback(() => { + if (isLoadingMore || isLoading) { + return; + } + if (!hasMore) { + return; + } + setIsLoadingMore(true); + loadBytes({ + showLoader: false, + nextPage: pageIndex + 1, + append: true, + }).finally(() => { + if (requestGuard.isMounted()) { + setIsLoadingMore(false); + } + }); + }, [hasMore, isLoading, isLoadingMore, loadBytes, pageIndex, requestGuard]); + + return ( + + + + String(item.id)} + renderItem={({ item }) => } + onEndReached={handleLoadMore} + onEndReachedThreshold={0.5} + onScroll={onScroll} + scrollEventThrottle={16} + refreshControl={ + + } + ListHeaderComponent={ + + + + + + All bytes + + + Full feed of shipped updates. + + + + + {["all", "following", "saved"].map((key) => { + const isActive = key === activeFilter; + return ( + + setActiveFilter(key as "all" | "following" | "saved") + } + style={[ + styles.filterChip, + { + backgroundColor: isActive + ? colors.tint + : colors.surfaceAlt, + borderColor: colors.border, + }, + ]} + > + + {key === "all" + ? "All" + : key === "following" + ? "Following" + : "Saved"} + + + ); + })} + + + {[ + { key: "recent", label: "Recent" }, + { key: "new", label: "Newest" }, + { key: "popular", label: "Popular" }, + { key: "hot", label: "HOT" }, + ].map(({ key, label }) => { + const isActive = key === activeSort; + return ( + + setActiveSort( + key as "recent" | "new" | "popular" | "hot", + ) + } + style={[ + styles.sortChip, + { + backgroundColor: isActive + ? colors.tint + : colors.surfaceAlt, + borderColor: colors.border, + }, + ]} + > + + {label} + + + ); + })} + + + {isLoading ? ( + + ) : null} + + } + ListEmptyComponent={ + !isLoading ? ( + + + {hasError + ? "Feed unavailable. Check the API and try again." + : activeFilter === "following" + ? "No bytes from people you follow yet." + : activeFilter === "saved" + ? "No saved bytes yet." + : "No bytes yet."} + + + ) : null + } + ListFooterComponent={ + isLoadingMore ? ( + + ) : null + } + contentContainerStyle={[ + styles.container, + { paddingTop: 8, paddingBottom: 96 + insets.bottom }, + ]} + removeClippedSubviews + initialNumToRender={6} + maxToRenderPerBatch={5} + windowSize={5} + updateCellsBatchingPeriod={60} + /> + + + + listRef.current?.scrollToOffset({ offset: 0, animated: true }) + } + bottomOffset={insets.bottom + 20} + /> + + ); +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + }, + safeArea: { + flex: 1, + }, + background: { + ...StyleSheet.absoluteFillObject, + }, + container: { + paddingVertical: 16, + paddingHorizontal: 16, + gap: 16, + paddingTop: 0, + }, + title: { + fontSize: 26, + lineHeight: 30, + }, + headerRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "flex-start", + gap: 12, + }, + filterRow: { + flexDirection: "row", + gap: 8, + paddingTop: 6, + }, + sortRow: { + flexDirection: "row", + gap: 8, + paddingTop: 8, + flexWrap: "wrap", + }, + filterChip: { + paddingVertical: 6, + paddingHorizontal: 10, + borderRadius: 10, + borderWidth: 1, + }, + sortChip: { + paddingVertical: 6, + paddingHorizontal: 10, + borderRadius: 10, + borderWidth: 1, + }, + emptyState: { + alignItems: "center", + paddingVertical: 24, + }, +}); diff --git a/frontend/app/create-byte.tsx b/frontend/app/create-byte.tsx new file mode 100644 index 0000000..1531828 --- /dev/null +++ b/frontend/app/create-byte.tsx @@ -0,0 +1,483 @@ +import React, { useEffect, useRef, useState } from "react"; +import { + ActivityIndicator, + Animated, + Keyboard, + KeyboardAvoidingView, + Platform, + Pressable, + ScrollView, + StyleSheet, + TextInput, + TouchableWithoutFeedback, + View, +} from "react-native"; +import { Picker } from "@react-native-picker/picker"; +import { useRouter } from "expo-router"; +import { + SafeAreaView, + useSafeAreaInsets, +} from "react-native-safe-area-context"; +import { ApiProject } from "@/constants/Types"; +import { FloatingScrollTopButton } from "@/components/FloatingScrollTopButton"; +import { ThemedText } from "@/components/ThemedText"; +import { TopBlur } from "@/components/TopBlur"; +import { useBottomTabOverflow } from "@/components/ui/TabBarBackground"; +import { useAuth } from "@/contexts/AuthContext"; +import { useTopBlurScroll } from "@/hooks/useTopBlurScroll"; +import { + createPost, + getProjectsByBuilderId, + uploadMedia, +} from "@/services/api"; +import { useAppColors } from "@/hooks/useAppColors"; +import { useMotionConfig } from "@/hooks/useMotionConfig"; +import * as DocumentPicker from "expo-document-picker"; +import * as ImagePicker from "expo-image-picker"; +import { MediaGallery } from "@/components/MediaGallery"; +import { MarkdownText } from "@/components/MarkdownText"; + +export default function CreateByteScreen() { + const colors = useAppColors(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const { user } = useAuth(); + const motion = useMotionConfig(); + const bottom = useBottomTabOverflow(); + const reveal = React.useRef(new Animated.Value(0.08)).current; + const scrollRef = useRef(null); + const { scrollY, onScroll } = useTopBlurScroll(); + const [projects, setProjects] = useState([]); + const [projectId, setProjectId] = useState(null); + const [content, setContent] = useState(""); + const [media, setMedia] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isUploading, setIsUploading] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + + useEffect(() => { + const loadProjects = async () => { + if (!user?.id) { + setIsLoading(false); + return; + } + try { + const data = await getProjectsByBuilderId(user.id); + const safeProjects = Array.isArray(data) ? data : []; + setProjects(safeProjects); + if (safeProjects.length) { + setProjectId(safeProjects[0].id); + } + } catch { + setProjects([]); + setErrorMessage("Unable to load your streams."); + } finally { + setIsLoading(false); + } + }; + + loadProjects(); + }, [user?.id]); + + useEffect(() => { + if (motion.prefersReducedMotion) { + reveal.setValue(1); + return; + } + + Animated.timing(reveal, { + toValue: 1, + duration: motion.duration(320), + useNativeDriver: true, + }).start(); + }, [motion, reveal]); + + const handleInputFocus = (event: any) => { + const target = event?.target ?? event?.nativeEvent?.target; + if (!target) { + return; + } + scrollRef.current?.scrollResponderScrollNativeHandleToKeyboard( + target, + 84, + true, + ); + }; + + const handleSubmit = async () => { + if (!user?.id || !projectId || !content.trim()) { + setErrorMessage("Pick a stream and add your update."); + return; + } + + setIsSubmitting(true); + setErrorMessage(""); + try { + await createPost({ + user: user.id, + project: projectId, + content, + media, + }); + router.replace("/(tabs)"); + } catch { + setErrorMessage("Failed to post. Try again."); + } finally { + setIsSubmitting(false); + } + }; + + const handleAddMedia = async (source: "file" | "library") => { + if (isUploading) { + return; + } + setErrorMessage(""); + setIsUploading(true); + try { + let files: { uri: string; name: string; type: string }[] = []; + if (source === "library") { + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ["images", "videos"], + quality: 0.9, + }); + if (!result.canceled && result.assets?.length) { + files = result.assets.map((asset) => ({ + uri: asset.uri, + name: asset.fileName ?? `media-${Date.now()}`, + type: asset.type ?? "application/octet-stream", + })); + } + } else { + const result = await DocumentPicker.getDocumentAsync({ + type: "*/*", + copyToCacheDirectory: true, + }); + if (!result.canceled) { + files = [ + { + uri: result.assets[0].uri, + name: result.assets[0].name, + type: result.assets[0].mimeType ?? "application/octet-stream", + }, + ]; + } + } + + if (!files.length) { + return; + } + + const uploads = await Promise.all(files.map((file) => uploadMedia(file))); + const urls = uploads.map((item) => item.url); + setMedia((prev) => [...prev, ...urls]); + } catch { + setErrorMessage("Upload failed. Try again."); + } finally { + setIsUploading(false); + } + }; + + return ( + + + + + + + + New byte + + Share a quick update. + + + + {isLoading ? ( + + + + ) : ( + + {projects.length ? ( + + setProjectId(Number(value))} + style={{ color: colors.text }} + dropdownIconColor={colors.muted} + > + {projects.map((project) => ( + + ))} + + + ) : ( + + + Create a stream before posting a byte. + + + )} + + + + + + {content.trim() ? ( + + + Preview + + {content} + + ) : null} + + + + Add media (images, video, files) + + + handleAddMedia("library")} + style={[ + styles.mediaButton, + { borderColor: colors.border }, + ]} + > + + Pick photo/video + + + handleAddMedia("file")} + style={[ + styles.mediaButton, + { borderColor: colors.border }, + ]} + > + + Pick file + + + + {isUploading ? ( + + + + Uploading... + + + ) : null} + + + + {errorMessage ? ( + + {errorMessage} + + ) : null} + + + {isSubmitting ? ( + + ) : ( + + Post byte + + )} + + + )} + + + + + + + scrollRef.current?.scrollTo({ y: 0, animated: true })} + bottomOffset={insets.bottom + 20} + /> + + ); +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + }, + content: { + flexGrow: 1, + paddingHorizontal: 16, + paddingBottom: 24, + }, + header: { + marginTop: 20, + gap: 6, + }, + loading: { + flex: 1, + alignItems: "center", + justifyContent: "center", + }, + form: { + marginTop: 20, + gap: 12, + }, + emptyState: { + borderRadius: 12, + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 16, + }, + inputRow: { + borderRadius: 12, + borderWidth: 1, + paddingHorizontal: 10, + paddingVertical: 6, + }, + input: { + fontFamily: "SpaceMono", + fontSize: 15, + }, + previewRow: { + borderRadius: 12, + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 10, + }, + previewLabel: { + marginBottom: 6, + }, + button: { + borderRadius: 12, + paddingVertical: 12, + alignItems: "center", + }, + mediaSection: { + gap: 10, + }, + mediaActions: { + flexDirection: "row", + gap: 10, + }, + mediaButton: { + borderRadius: 10, + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 8, + }, + uploadingRow: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, +}); diff --git a/frontend/app/create-stream.tsx b/frontend/app/create-stream.tsx new file mode 100644 index 0000000..51ccb4c --- /dev/null +++ b/frontend/app/create-stream.tsx @@ -0,0 +1,530 @@ +import React, { useEffect, useRef, useState } from "react"; +import { + ActivityIndicator, + Animated, + Keyboard, + KeyboardAvoidingView, + Platform, + Pressable, + ScrollView, + StyleSheet, + TextInput, + TouchableWithoutFeedback, + View, +} from "react-native"; +import { + SafeAreaView, + useSafeAreaInsets, +} from "react-native-safe-area-context"; +import { Picker } from "@react-native-picker/picker"; +import { useRouter } from "expo-router"; +import { FloatingScrollTopButton } from "@/components/FloatingScrollTopButton"; +import { ThemedText } from "@/components/ThemedText"; +import { TopBlur } from "@/components/TopBlur"; +import { useBottomTabOverflow } from "@/components/ui/TabBarBackground"; +import { useAuth } from "@/contexts/AuthContext"; +import { useTopBlurScroll } from "@/hooks/useTopBlurScroll"; +import { createProject, uploadMedia } from "@/services/api"; +import { useAppColors } from "@/hooks/useAppColors"; +import { useMotionConfig } from "@/hooks/useMotionConfig"; +import * as DocumentPicker from "expo-document-picker"; +import * as ImagePicker from "expo-image-picker"; +import { MediaGallery } from "@/components/MediaGallery"; +import { MarkdownText } from "@/components/MarkdownText"; + +const statusOptions = [ + { label: "Alpha", value: 0 }, + { label: "Beta", value: 1 }, + { label: "Launch", value: 2 }, +]; + +export default function CreateStreamScreen() { + const colors = useAppColors(); + const insets = useSafeAreaInsets(); + const router = useRouter(); + const { user } = useAuth(); + const motion = useMotionConfig(); + const bottom = useBottomTabOverflow(); + const reveal = React.useRef(new Animated.Value(0.08)).current; + const scrollRef = useRef(null); + const { scrollY, onScroll } = useTopBlurScroll(); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [aboutMd, setAboutMd] = useState(""); + const [tags, setTags] = useState(""); + const [links, setLinks] = useState(""); + const [status, setStatus] = useState(0); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isUploading, setIsUploading] = useState(false); + const [media, setMedia] = useState([]); + const [errorMessage, setErrorMessage] = useState(""); + + useEffect(() => { + if (motion.prefersReducedMotion) { + reveal.setValue(1); + return; + } + + Animated.timing(reveal, { + toValue: 1, + duration: motion.duration(320), + useNativeDriver: true, + }).start(); + }, [motion, reveal]); + + const handleInputFocus = (event: any) => { + const target = event?.target ?? event?.nativeEvent?.target; + if (!target) { + return; + } + scrollRef.current?.scrollResponderScrollNativeHandleToKeyboard( + target, + 84, + true, + ); + }; + + const handleSubmit = async () => { + if (!user?.id || !name.trim() || !description.trim()) { + setErrorMessage("Add a name and description for your stream."); + return; + } + + setIsSubmitting(true); + setErrorMessage(""); + try { + const tagList = tags + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + const linkList = links + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + + await createProject({ + owner: user.id, + name: name.trim(), + description: description.trim(), + about_md: aboutMd.trim(), + status, + tags: tagList, + links: linkList, + media, + }); + router.replace("/(tabs)"); + } catch { + setErrorMessage("Failed to create stream. Try again."); + } finally { + setIsSubmitting(false); + } + }; + + const handleAddMedia = async (source: "file" | "library") => { + if (isUploading) { + return; + } + setIsUploading(true); + setErrorMessage(""); + try { + let files: { uri: string; name: string; type: string }[] = []; + if (source === "library") { + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ["images", "videos"], + quality: 0.9, + }); + if (!result.canceled && result.assets?.length) { + files = result.assets.map((asset) => ({ + uri: asset.uri, + name: asset.fileName ?? `media-${Date.now()}`, + type: asset.type ?? "application/octet-stream", + })); + } + } else { + const result = await DocumentPicker.getDocumentAsync({ + type: "*/*", + copyToCacheDirectory: true, + }); + if (!result.canceled) { + files = [ + { + uri: result.assets[0].uri, + name: result.assets[0].name, + type: result.assets[0].mimeType ?? "application/octet-stream", + }, + ]; + } + } + + if (!files.length) { + return; + } + + const uploads = await Promise.all(files.map((file) => uploadMedia(file))); + const urls = uploads.map((item) => item.url); + setMedia((prev) => [...prev, ...urls]); + } catch { + setErrorMessage("Upload failed. Try again."); + } finally { + setIsUploading(false); + } + }; + + return ( + + + + + + + + New stream + + Start a project log. + + + + + + + + + {aboutMd.trim() ? ( + + + Body preview + + {aboutMd} + + ) : null} + + + + + + {description.trim() ? ( + + + Preview + + {description} + + ) : null} + + + + + + + setStatus(Number(value))} + style={{ color: colors.text }} + dropdownIconColor={colors.muted} + > + {statusOptions.map((option) => ( + + ))} + + + + + + + + + + + + + + Add media (images, video, files) + + + handleAddMedia("library")} + style={[ + styles.mediaButton, + { borderColor: colors.border }, + ]} + > + + Pick photo/video + + + handleAddMedia("file")} + style={[ + styles.mediaButton, + { borderColor: colors.border }, + ]} + > + + Pick file + + + + {isUploading ? ( + + + + Uploading... + + + ) : null} + + + + {errorMessage ? ( + + {errorMessage} + + ) : null} + + + {isSubmitting ? ( + + ) : ( + + Create stream + + )} + + + + + + + + + scrollRef.current?.scrollTo({ y: 0, animated: true })} + bottomOffset={insets.bottom + 20} + /> + + ); +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + }, + content: { + flexGrow: 1, + paddingHorizontal: 16, + paddingBottom: 24, + }, + header: { + marginTop: 20, + gap: 6, + }, + form: { + marginTop: 20, + gap: 12, + }, + inputRow: { + borderRadius: 12, + borderWidth: 1, + paddingHorizontal: 10, + paddingVertical: 6, + }, + input: { + fontFamily: "SpaceMono", + fontSize: 15, + }, + previewRow: { + borderRadius: 12, + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 10, + }, + previewLabel: { + marginBottom: 6, + }, + button: { + borderRadius: 12, + paddingVertical: 12, + alignItems: "center", + }, + mediaSection: { + gap: 10, + }, + mediaActions: { + flexDirection: "row", + gap: 10, + }, + mediaButton: { + borderRadius: 10, + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 8, + }, + uploadingRow: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, +}); diff --git a/frontend/app/manage-stream/[projectId].tsx b/frontend/app/manage-stream/[projectId].tsx new file mode 100644 index 0000000..33154b2 --- /dev/null +++ b/frontend/app/manage-stream/[projectId].tsx @@ -0,0 +1,709 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { + ActivityIndicator, + Alert, + Animated, + Keyboard, + KeyboardAvoidingView, + Platform, + Pressable, + ScrollView, + StyleSheet, + TextInput, + TouchableWithoutFeedback, + View, +} from "react-native"; +import { + SafeAreaView, + useSafeAreaInsets, +} from "react-native-safe-area-context"; +import { Picker } from "@react-native-picker/picker"; +import { useLocalSearchParams, useRouter } from "expo-router"; +import * as DocumentPicker from "expo-document-picker"; +import * as ImagePicker from "expo-image-picker"; +import { ThemedText } from "@/components/ThemedText"; +import { TopBlur } from "@/components/TopBlur"; +import { FloatingScrollTopButton } from "@/components/FloatingScrollTopButton"; +import { MediaGallery } from "@/components/MediaGallery"; +import { MarkdownText } from "@/components/MarkdownText"; +import { useTopBlurScroll } from "@/hooks/useTopBlurScroll"; +import { useAppColors } from "@/hooks/useAppColors"; +import { useMotionConfig } from "@/hooks/useMotionConfig"; +import { + clearApiCache, + getProjectById, + updateProject, + uploadMedia, +} from "@/services/api"; +import { emitProjectUpdated } from "@/services/projectEvents"; + +type StatusOption = { label: string; value: number }; + +const statusOptions: StatusOption[] = [ + { label: "Alpha", value: 0 }, + { label: "Beta", value: 1 }, + { label: "Launch", value: 2 }, +]; + +export default function ManageSingleStreamScreen() { + const colors = useAppColors(); + const insets = useSafeAreaInsets(); + const router = useRouter(); + const motion = useMotionConfig(); + const reveal = useRef(new Animated.Value(0.08)).current; + const scrollRef = useRef(null); + const { scrollY, onScroll } = useTopBlurScroll(); + const { projectId } = useLocalSearchParams<{ projectId?: string }>(); + const projectIdNumber = useMemo(() => Number(projectId), [projectId]); + + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [aboutMd, setAboutMd] = useState(""); + const [tags, setTags] = useState(""); + const [links, setLinks] = useState(""); + const [status, setStatus] = useState(0); + const [media, setMedia] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [isUploading, setIsUploading] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + + useEffect(() => { + if (motion.prefersReducedMotion) { + reveal.setValue(1); + return; + } + + Animated.timing(reveal, { + toValue: 1, + duration: motion.duration(300), + useNativeDriver: true, + }).start(); + }, [motion, reveal]); + + useEffect(() => { + let cancelled = false; + if (!projectIdNumber) { + setErrorMessage("Invalid stream id."); + setIsLoading(false); + return; + } + + void (async () => { + setIsLoading(true); + setErrorMessage(""); + try { + const project = await getProjectById(projectIdNumber); + if (cancelled) { + return; + } + setName(project.name ?? ""); + setDescription(project.description ?? ""); + setAboutMd(project.about_md ?? ""); + setTags((project.tags ?? []).join(", ")); + setLinks((project.links ?? []).join(", ")); + setStatus(project.status ?? 0); + setMedia(project.media ?? []); + } catch { + if (!cancelled) { + setErrorMessage("Unable to load stream."); + } + } finally { + if (!cancelled) { + setIsLoading(false); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [projectIdNumber]); + + const getMediaLabel = (url: string) => { + const trimmed = url.split("?")[0].split("#")[0]; + return trimmed.split("/").pop() || "attachment"; + }; + + const handleInputFocus = (event: any) => { + const target = event?.target ?? event?.nativeEvent?.target; + if (!target) { + return; + } + scrollRef.current?.scrollResponderScrollNativeHandleToKeyboard( + target, + 84, + true, + ); + }; + + const handleAddMedia = async (source: "file" | "library") => { + if (isUploading) { + return; + } + setIsUploading(true); + setErrorMessage(""); + try { + let files: { uri: string; name: string; type: string }[] = []; + if (source === "library") { + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ["images", "videos"], + quality: 0.9, + }); + if (!result.canceled && result.assets?.length) { + files = result.assets.map((asset) => ({ + uri: asset.uri, + name: asset.fileName ?? `media-${Date.now()}`, + type: asset.type ?? "application/octet-stream", + })); + } + } else { + const result = await DocumentPicker.getDocumentAsync({ + type: "*/*", + copyToCacheDirectory: true, + }); + if (!result.canceled) { + files = [ + { + uri: result.assets[0].uri, + name: result.assets[0].name, + type: result.assets[0].mimeType ?? "application/octet-stream", + }, + ]; + } + } + + if (!files.length) { + return; + } + + const uploads = await Promise.all(files.map((file) => uploadMedia(file))); + const urls = uploads + .map((item) => item?.url) + .filter((url): url is string => !!url); + setMedia((prev) => [...prev, ...urls]); + } catch { + setErrorMessage("Upload failed. Try again."); + } finally { + setIsUploading(false); + } + }; + + const handleRemoveMedia = (url: string) => { + setMedia((prev) => prev.filter((item) => item !== url)); + }; + + const handleSave = async () => { + if (!projectIdNumber) { + return; + } + if (!name.trim() || !description.trim()) { + setErrorMessage("Add a stream name and summary."); + return; + } + + setIsSaving(true); + setErrorMessage(""); + try { + const payload = { + name: name.trim(), + description: description.trim(), + about_md: aboutMd.trim(), + status, + tags: tags + .split(",") + .map((item) => item.trim()) + .filter(Boolean), + links: links + .split(",") + .map((item) => item.trim()) + .filter(Boolean), + media, + }; + const response = await updateProject(projectIdNumber, payload); + const updated = response.project; + clearApiCache(); + emitProjectUpdated(updated.id, { + name: updated.name, + summary: updated.description ?? "", + about_md: updated.about_md ?? "", + stage: + updated.status === 2 + ? "launch" + : updated.status === 1 + ? "beta" + : "alpha", + tags: updated.tags ?? [], + media: updated.media ?? [], + updated_on: updated.creation_date, + likes: updated.likes, + saves: updated.saves ?? 0, + }); + router.back(); + } catch { + setErrorMessage("Failed to update stream."); + } finally { + setIsSaving(false); + } + }; + + const handleCancel = () => { + if (isSaving) { + return; + } + router.back(); + }; + + const confirmDiscard = () => { + if (isSaving) { + return; + } + Alert.alert("Discard changes?", "Your edits will be lost.", [ + { text: "Keep editing", style: "cancel" }, + { text: "Discard", style: "destructive", onPress: handleCancel }, + ]); + }; + + return ( + + + + + + + + Edit stream + + Update details in one clean editor. + + + + {isLoading ? ( + + + + ) : ( + + + + + + + + + + {description.trim() ? ( + + + Preview + + {description} + + ) : null} + + + + + + {aboutMd.trim() ? ( + + + Body preview + + {aboutMd} + + ) : null} + + + setStatus(Number(value))} + style={{ color: colors.text }} + dropdownIconColor={colors.muted} + > + {statusOptions.map((option) => ( + + ))} + + + + + + + + + + + + + + Edit attachments + + + handleAddMedia("library")} + style={[ + styles.mediaButton, + { borderColor: colors.border }, + ]} + disabled={isUploading} + > + + Add photo/video + + + handleAddMedia("file")} + style={[ + styles.mediaButton, + { borderColor: colors.border }, + ]} + disabled={isUploading} + > + + Add file + + + + {isUploading ? ( + + + + Uploading... + + + ) : null} + {media.length ? ( + + {media.map((item) => ( + handleRemoveMedia(item)} + style={[ + styles.mediaChip, + { borderColor: colors.border }, + ]} + > + + {getMediaLabel(item)} × + + + ))} + + ) : null} + + + + {errorMessage ? ( + + {errorMessage} + + ) : null} + + + + + Cancel + + + + {isSaving ? ( + + ) : ( + + Save + + )} + + + + )} + + + + + + + scrollRef.current?.scrollTo({ y: 0, animated: true })} + /> + + ); +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + }, + content: { + flexGrow: 1, + paddingHorizontal: 16, + paddingBottom: 24, + }, + header: { + marginTop: 20, + gap: 6, + }, + loadingState: { + flex: 1, + alignItems: "center", + justifyContent: "center", + paddingVertical: 24, + }, + form: { + marginTop: 20, + gap: 12, + }, + inputRow: { + borderRadius: 12, + borderWidth: 1, + paddingHorizontal: 10, + paddingVertical: 6, + }, + input: { + fontFamily: "SpaceMono", + fontSize: 15, + }, + previewRow: { + borderRadius: 12, + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 10, + }, + previewLabel: { + marginBottom: 6, + }, + mediaSection: { + gap: 10, + }, + mediaActions: { + flexDirection: "row", + gap: 10, + }, + mediaButton: { + borderRadius: 10, + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 8, + }, + uploadingRow: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + mediaChips: { + flexDirection: "row", + flexWrap: "wrap", + gap: 8, + }, + mediaChip: { + borderRadius: 10, + borderWidth: 1, + paddingHorizontal: 10, + paddingVertical: 6, + }, + actionRow: { + flexDirection: "row", + justifyContent: "flex-end", + gap: 10, + }, + actionButton: { + borderRadius: 10, + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 8, + minWidth: 78, + alignItems: "center", + }, +}); diff --git a/frontend/app/manage-streams.tsx b/frontend/app/manage-streams.tsx new file mode 100644 index 0000000..6f3a53d --- /dev/null +++ b/frontend/app/manage-streams.tsx @@ -0,0 +1,568 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { + Alert, + Animated, + Pressable, + RefreshControl, + ScrollView, + StyleSheet, + TextInput, + View, +} from "react-native"; +import { + SafeAreaView, + useSafeAreaInsets, +} from "react-native-safe-area-context"; +import { useFocusEffect } from "@react-navigation/native"; +import { useRouter } from "expo-router"; +import { FloatingScrollTopButton } from "@/components/FloatingScrollTopButton"; +import { ThemedText } from "@/components/ThemedText"; +import { TopBlur } from "@/components/TopBlur"; +import { useAutoRefresh } from "@/hooks/useAutoRefresh"; +import { useAppColors } from "@/hooks/useAppColors"; +import { useMotionConfig } from "@/hooks/useMotionConfig"; +import { useTopBlurScroll } from "@/hooks/useTopBlurScroll"; +import { useAuth } from "@/contexts/AuthContext"; +import { + addProjectBuilder, + clearApiCache, + deleteProject, + getProjectBuilders, + getProjectsByBuilderId, + removeProjectBuilder, +} from "@/services/api"; +import { ApiProject } from "@/constants/Types"; +import { TagChip } from "@/components/TagChip"; +import { MediaGallery } from "@/components/MediaGallery"; +import { MarkdownText } from "@/components/MarkdownText"; +import { emitProjectDeleted } from "@/services/projectEvents"; + +export default function ManageStreamsScreen() { + const colors = useAppColors(); + const insets = useSafeAreaInsets(); + const { user } = useAuth(); + const router = useRouter(); + const [projects, setProjects] = useState([]); + const [builderMap, setBuilderMap] = useState>({}); + const [builderDrafts, setBuilderDrafts] = useState>( + {}, + ); + const [deletingProjectId, setDeletingProjectId] = useState( + null, + ); + const [leavingProjectId, setLeavingProjectId] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [hasError, setHasError] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + const motion = useMotionConfig(); + const reveal = useRef(new Animated.Value(0.08)).current; + const scrollRef = useRef(null); + const { scrollY, onScroll } = useTopBlurScroll(); + + useEffect(() => { + if (motion.prefersReducedMotion) { + reveal.setValue(1); + return; + } + + Animated.timing(reveal, { + toValue: 1, + duration: motion.duration(420), + useNativeDriver: true, + }).start(); + }, [motion, reveal]); + + const loadStreams = useCallback( + async (showLoader = true) => { + if (!user?.id) { + if (showLoader) { + setIsLoading(false); + } + return; + } + try { + if (showLoader) { + setIsLoading(true); + } + const data = await getProjectsByBuilderId(user.id); + const safeProjects = Array.isArray(data) ? data : []; + setProjects(safeProjects); + + const builders = await Promise.all( + safeProjects.map(async (project) => { + const list = await getProjectBuilders(project.id).catch(() => []); + return [project.id, list] as const; + }), + ); + setBuilderMap(Object.fromEntries(builders)); + setHasError(false); + } catch { + setProjects([]); + setHasError(true); + } finally { + if (showLoader) { + setIsLoading(false); + } + } + }, + [user?.id], + ); + + useEffect(() => { + loadStreams(); + }, [loadStreams]); + + useFocusEffect( + useCallback(() => { + clearApiCache(); + loadStreams(false); + }, [loadStreams]), + ); + + const handleRefresh = useCallback(async () => { + setIsRefreshing(true); + clearApiCache(); + await loadStreams(false); + setIsRefreshing(false); + }, [loadStreams]); + + useAutoRefresh(() => loadStreams(false), { focusRefresh: false }); + + const handleDeleteProject = (projectId: number) => { + if (deletingProjectId) { + return; + } + Alert.alert( + "Delete stream?", + "This deletes the stream and all of its bytes. This action cannot be undone.", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: () => { + void (async () => { + setDeletingProjectId(projectId); + try { + await deleteProject(projectId); + emitProjectDeleted(projectId); + setProjects((prev) => + prev.filter((item) => item.id !== projectId), + ); + } finally { + setDeletingProjectId(null); + } + })(); + }, + }, + ], + ); + }; + + const handleAddBuilder = async (projectId: number) => { + const username = builderDrafts[projectId]?.trim(); + if (!username) { + return; + } + await addProjectBuilder(projectId, username); + const list = await getProjectBuilders(projectId).catch(() => []); + setBuilderMap((prev) => ({ ...prev, [projectId]: list })); + setBuilderDrafts((prev) => ({ ...prev, [projectId]: "" })); + }; + + const handleRemoveBuilder = async (projectId: number, username: string) => { + await removeProjectBuilder(projectId, username); + const list = await getProjectBuilders(projectId).catch(() => []); + setBuilderMap((prev) => ({ ...prev, [projectId]: list })); + }; + + const handleLeaveProject = (projectId: number) => { + if (!user?.username || leavingProjectId) { + return; + } + Alert.alert( + "Leave stream?", + "You will be removed as a builder from this stream.", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Leave", + style: "destructive", + onPress: () => { + void (async () => { + setLeavingProjectId(projectId); + try { + await removeProjectBuilder(projectId, user.username); + setProjects((prev) => + prev.filter((item) => item.id !== projectId), + ); + setBuilderMap((prev) => { + const next = { ...prev }; + delete next[projectId]; + return next; + }); + } finally { + setLeavingProjectId(null); + } + })(); + }, + }, + ], + ); + }; + + return ( + + + + } + contentContainerStyle={[ + styles.container, + { paddingTop: 8, paddingBottom: 96 + insets.bottom }, + ]} + > + + + Manage streams + + + Your active projects and stream logs. + + + + {isLoading ? ( + + {[0, 1].map((key) => ( + + ))} + + ) : projects.length ? ( + + {projects.map((project) => { + const builders = builderMap[project.id] ?? []; + const isOwner = project.owner === user?.id; + const stageLabel = + project.status === 2 + ? "launch" + : project.status === 1 + ? "beta" + : "alpha"; + + return ( + + + + + {project.name.replace(/\s+/g, " ").trim()} + + + {stageLabel.toUpperCase()} · Owner #{project.owner} + + + + + + + + {(project.description ?? "") + .replace(/\s+/g, " ") + .trim()} + + {project.about_md ? ( + + {project.about_md} + + ) : null} + + + + + + Builders:{" "} + {builders.length ? builders.join(", ") : "none"} + + {isOwner ? ( + + + setBuilderDrafts((prev) => ({ + ...prev, + [project.id]: value, + })) + } + placeholder="Add builder by username" + placeholderTextColor={colors.muted} + style={[ + styles.input, + styles.builderInput, + { + color: colors.text, + borderColor: colors.border, + backgroundColor: colors.surfaceAlt, + }, + ]} + /> + handleAddBuilder(project.id)} + style={[ + styles.builderButton, + { borderColor: colors.border }, + ]} + > + + Add + + + + ) : null} + + {isOwner && builders.length ? ( + + {builders.map((builder) => ( + + handleRemoveBuilder(project.id, builder) + } + style={[ + styles.builderChip, + { borderColor: colors.border }, + ]} + > + + {builder} × + + + ))} + + ) : null} + + + + + router.push({ + pathname: "/manage-stream/[projectId]", + params: { projectId: String(project.id) }, + }) + } + style={[ + styles.actionButton, + { borderColor: colors.border }, + ]} + > + + Edit + + + {isOwner ? ( + handleDeleteProject(project.id)} + style={[ + styles.actionButton, + { borderColor: colors.border }, + ]} + disabled={deletingProjectId === project.id} + > + + Delete + + + ) : ( + handleLeaveProject(project.id)} + style={[ + styles.actionButton, + { borderColor: colors.border }, + ]} + disabled={leavingProjectId === project.id} + > + + Leave + + + )} + + + ); + })} + + ) : ( + + + {hasError ? "Streams unavailable." : "No streams yet."} + + + )} + + + + scrollRef.current?.scrollTo({ y: 0, animated: true })} + bottomOffset={insets.bottom + 20} + /> + + ); +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + }, + safeArea: { + flex: 1, + }, + container: { + paddingVertical: 16, + paddingHorizontal: 16, + gap: 16, + paddingTop: 0, + }, + title: { + fontSize: 26, + lineHeight: 30, + }, + list: { + gap: 12, + }, + card: { + borderRadius: 16, + borderWidth: 1, + padding: 14, + gap: 12, + }, + cardHeader: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + gap: 10, + }, + previewBlock: { + gap: 8, + }, + input: { + borderRadius: 10, + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 8, + fontFamily: "SpaceMono", + fontSize: 14, + }, + actionRow: { + flexDirection: "row", + gap: 10, + }, + actionButton: { + borderRadius: 10, + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 8, + }, + builderBlock: { + gap: 10, + }, + builderRow: { + flexDirection: "row", + gap: 10, + alignItems: "center", + }, + builderInput: { + flex: 1, + }, + builderButton: { + borderRadius: 10, + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 8, + }, + builderTags: { + flexDirection: "row", + flexWrap: "wrap", + gap: 8, + }, + builderChip: { + borderRadius: 999, + borderWidth: 1, + paddingHorizontal: 10, + paddingVertical: 6, + }, + skeletonStack: { + gap: 12, + }, + skeletonCard: { + borderRadius: 14, + height: 110, + borderWidth: 1, + opacity: 0.7, + }, + emptyState: { + alignItems: "center", + paddingVertical: 24, + }, +}); diff --git a/frontend/app/markdown-help.tsx b/frontend/app/markdown-help.tsx new file mode 100644 index 0000000..92c7535 --- /dev/null +++ b/frontend/app/markdown-help.tsx @@ -0,0 +1,253 @@ +import React, { useRef, useState } from "react"; +import { + Animated, + Pressable, + ScrollView, + StyleSheet, + Text, + View, +} from "react-native"; +import { + SafeAreaView, + useSafeAreaInsets, +} from "react-native-safe-area-context"; +import { Feather } from "@expo/vector-icons"; +import { useRouter } from "expo-router"; +import { FloatingScrollTopButton } from "@/components/FloatingScrollTopButton"; +import { MarkdownText } from "@/components/MarkdownText"; +import { ThemedText } from "@/components/ThemedText"; +import { TopBlur } from "@/components/TopBlur"; +import { useAppColors } from "@/hooks/useAppColors"; +import { useTopBlurScroll } from "@/hooks/useTopBlurScroll"; + +type MarkdownSample = { + title: string; + note?: string; + code: string; +}; + +const markdownSamples: MarkdownSample[] = [ + { + title: "Core formatting", + code: `**bold**\n*italic*\n~~strikethrough~~\n\`inline code\``, + }, + { + title: "Headings", + code: `# Heading 1\n## Heading 2\n### Heading 3`, + }, + { + title: "Links", + note: "Plain URLs are auto-linkified too.", + code: `[DevBits](https://example.com)\nhttps://example.com\nwww.example.com/docs`, + }, + { + title: "Images", + note: "Supports standard image URLs, .svg files, and GitHub blob/raw links.", + code: `![DevBits SVG](https://github.com/devbits-go/.github/blob/main/profile/svg/DevBits.svg)`, + }, + { + title: "Lists and tasks", + code: `- Item one\n- Item two\n\n1. First\n2. Second\n\n- [ ] Todo\n- [x] Done`, + }, + { + title: "Blockquotes and callouts", + code: `> Regular blockquote\n\n> [!NOTE]\n> Helpful note\n\n> [!TIP]\n> Helpful tip\n\n> [!IMPORTANT]\n> Important info\n\n> [!WARNING]\n> Warning text\n\n> [!CAUTION]\n> Caution text`, + }, + { + title: "Code blocks", + code: "```ts\nconst greet = (name: string) => 'Hello, ' + name;\n```", + }, + { + title: "Table", + code: `| Feature | Supported |\n| --- | --- |\n| Tables | Yes |\n| Task lists | Yes |`, + }, + { + title: "Horizontal rule", + code: `---`, + }, + { + title: "Dropdown (collapsed by default)", + code: `
\nOpen details\nHidden content here.\n
`, + }, + { + title: "Dropdown (open by default)", + note: "Use the open attribute on
.", + code: `
\nOpen by default\nThis starts expanded.\n
`, + }, + { + title: "Styled summary", + note: "Summary text supports markdown styling in your renderer.", + code: `
\n**Build Notes** · *v2* · ~~old~~ · [spec](https://example.com)\nSummary can include bold/italic/strike/link styling.\n
`, + }, +]; + +export default function MarkdownHelpScreen() { + const colors = useAppColors(); + const insets = useSafeAreaInsets(); + const router = useRouter(); + const scrollRef = useRef(null); + const [activePreview, setActivePreview] = useState(null); + const { scrollY, onScroll } = useTopBlurScroll(); + + return ( + + + + + router.back()} + style={[styles.backButton, { borderColor: colors.border }]} + > + + + Markdown help + + + + + Raw syntax examples for everything currently supported by your + custom markdown renderer. + + + {markdownSamples.map((sample) => ( + + {sample.title} + {sample.note ? ( + + {sample.note} + + ) : null} + + + {sample.code} + + + + setActivePreview((prev) => + prev === sample.title ? null : sample.title, + ) + } + style={[ + styles.previewButton, + { + borderColor: colors.border, + backgroundColor: colors.surfaceAlt, + }, + ]} + > + + {activePreview === sample.title + ? "Hide rendered preview" + : "Render preview"} + + + {activePreview === sample.title ? ( + + {sample.code} + + ) : null} + + ))} + + + + + + scrollRef.current?.scrollTo({ y: 0, animated: true })} + bottomOffset={insets.bottom + 20} + /> + + ); +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + }, + container: { + paddingHorizontal: 16, + gap: 14, + }, + headerRow: { + flexDirection: "row", + alignItems: "center", + gap: 10, + }, + backButton: { + width: 34, + height: 34, + borderRadius: 10, + borderWidth: 1, + alignItems: "center", + justifyContent: "center", + }, + card: { + borderRadius: 14, + borderWidth: 1, + padding: 14, + gap: 14, + }, + sampleBlock: { + gap: 8, + }, + codeCard: { + borderRadius: 12, + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 10, + }, + codeText: { + fontFamily: "SpaceMono", + fontSize: 13, + lineHeight: 19, + }, + previewButton: { + borderRadius: 10, + borderWidth: 1, + paddingHorizontal: 10, + paddingVertical: 8, + alignSelf: "flex-start", + }, + previewCard: { + borderRadius: 12, + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 10, + }, +}); diff --git a/frontend/app/notifications.tsx b/frontend/app/notifications.tsx new file mode 100644 index 0000000..a739b98 --- /dev/null +++ b/frontend/app/notifications.tsx @@ -0,0 +1,326 @@ +import React, { useEffect, useRef } from "react"; +import { + Animated, + FlatList, + Pressable, + RefreshControl, + StyleSheet, + View, +} from "react-native"; +import { + SafeAreaView, + useSafeAreaInsets, +} from "react-native-safe-area-context"; +import { useRouter } from "expo-router"; +import { FloatingScrollTopButton } from "@/components/FloatingScrollTopButton"; +import { ThemedText } from "@/components/ThemedText"; +import { TopBlur } from "@/components/TopBlur"; +import { useAppColors } from "@/hooks/useAppColors"; +import { useMotionConfig } from "@/hooks/useMotionConfig"; +import { useTopBlurScroll } from "@/hooks/useTopBlurScroll"; +import { useNotifications } from "@/contexts/NotificationsContext"; + +export default function NotificationsScreen() { + const colors = useAppColors(); + const insets = useSafeAreaInsets(); + const router = useRouter(); + const motion = useMotionConfig(); + const reveal = useRef(new Animated.Value(0.08)).current; + const listRef = useRef>(null); + const { scrollY, onScroll } = useTopBlurScroll(); + const { + notifications, + isLoading, + isLoadingMore, + hasMore, + refresh, + loadMore, + markRead, + remove, + clearAll, + } = useNotifications(); + + useEffect(() => { + if (motion.prefersReducedMotion) { + reveal.setValue(1); + return; + } + + Animated.timing(reveal, { + toValue: 1, + duration: motion.duration(360), + useNativeDriver: true, + }).start(); + }, [motion, reveal]); + + const getNotificationTitle = (type: string) => { + switch (type) { + case "direct_message": + return "New message"; + case "builder_added": + return "Builder invite"; + case "comment_post": + return "New comment"; + case "save_post": + return "Byte saved"; + case "save_project": + return "Stream saved"; + case "follow_user": + return "New follower"; + default: + return "Notification"; + } + }; + + const getNotificationBody = (item: { type: string; actor_name: string }) => { + const actor = item.actor_name || "Someone"; + switch (item.type) { + case "direct_message": + return `${actor} sent you a message.`; + case "builder_added": + return `${actor} added you as a builder.`; + case "comment_post": + return `${actor} commented on your byte.`; + case "save_post": + return `${actor} saved your byte.`; + case "save_project": + return `${actor} saved your stream.`; + case "follow_user": + return `${actor} followed you.`; + default: + return `${actor} sent you a notification.`; + } + }; + + const handleOpen = async (item: any) => { + await markRead(item.id); + if (item.type === "direct_message" && item.actor_name) { + router.push({ + pathname: "/terminal", + params: { chat: item.actor_name }, + }); + return; + } + if (item.type === "follow_user" && item.actor_name) { + router.push({ + pathname: "/user/[username]", + params: { username: item.actor_name }, + }); + return; + } + if ( + (item.type === "save_project" || item.type === "builder_added") && + item.project_id + ) { + router.push({ + pathname: "/stream/[projectId]", + params: { projectId: String(item.project_id) }, + }); + return; + } + if ( + (item.type === "save_post" || item.type === "comment_post") && + item.post_id + ) { + router.push({ + pathname: "/post/[postId]", + params: { postId: String(item.post_id) }, + }); + } + }; + + return ( + + + String(item.id)} + renderItem={({ item }) => ( + + + + {getNotificationTitle(item.type)} + + remove(item.id)} + style={[styles.deleteButton, { borderColor: colors.border }]} + > + + Delete + + + + void handleOpen(item)}> + + {getNotificationBody(item)} + + + {new Date(item.created_at).toLocaleString()} + + + + )} + onScroll={onScroll} + scrollEventThrottle={16} + onEndReached={() => { + if (hasMore && !isLoadingMore) { + void loadMore(); + } + }} + onEndReachedThreshold={0.45} + initialNumToRender={8} + maxToRenderPerBatch={8} + updateCellsBatchingPeriod={40} + windowSize={8} + removeClippedSubviews + refreshControl={ + + } + contentContainerStyle={{ paddingBottom: 96 + insets.bottom }} + ListHeaderComponent={ + + + + Notifications + + {notifications.length ? "New activity" : "Nothing new yet."} + + + {notifications.length ? ( + + + Clear all + + + ) : null} + + + + } + ListEmptyComponent={ + + + You are all caught up. + + + } + ListFooterComponent={ + isLoadingMore ? ( + + + Loading more... + + + ) : null + } + ItemSeparatorComponent={() => } + style={styles.listContainer} + /> + + + + listRef.current?.scrollToOffset({ offset: 0, animated: true }) + } + bottomOffset={insets.bottom + 20} + /> + + ); +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + }, + content: { + paddingHorizontal: 16, + gap: 6, + }, + listContainer: { + flex: 1, + }, + headerRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: 12, + }, + clearButton: { + borderRadius: 10, + borderWidth: 1, + paddingHorizontal: 10, + paddingVertical: 6, + }, + list: { + marginTop: 16, + }, + loadingMoreState: { + paddingHorizontal: 16, + paddingVertical: 16, + alignItems: "center", + }, + card: { + borderRadius: 14, + borderWidth: 1, + padding: 12, + gap: 8, + }, + cardHeader: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: 10, + }, + deleteButton: { + borderRadius: 8, + borderWidth: 1, + paddingHorizontal: 8, + paddingVertical: 4, + }, + emptyState: { + marginTop: 24, + }, +}); diff --git a/frontend/app/post/[postId].tsx b/frontend/app/post/[postId].tsx new file mode 100644 index 0000000..b9207c7 --- /dev/null +++ b/frontend/app/post/[postId].tsx @@ -0,0 +1,1725 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + ActivityIndicator, + Alert, + Animated, + Keyboard, + KeyboardAvoidingView, + Platform, + Pressable, + RefreshControl, + StyleSheet, + TextInput, + TouchableWithoutFeedback, + View, +} from "react-native"; +import { + SafeAreaView, + useSafeAreaInsets, +} from "react-native-safe-area-context"; +import { useFocusEffect } from "@react-navigation/native"; +import { useLocalSearchParams, useRouter } from "expo-router"; +import { Feather } from "@expo/vector-icons"; +import { ApiComment, ApiPost, ApiProject, ApiUser } from "@/constants/Types"; +import { + clearApiCache, + createCommentOnPost, + deleteComment, + deletePost, + getCommentsByPostId, + getPostById, + getProjectById, + getUserById, + isPostLiked, + likePost, + isCommentLiked, + likeComment, + updateComment, + updatePost, + uploadMedia, + unlikePost, + unlikeComment, +} from "@/services/api"; +import { + emitPostDeleted, + emitPostStats, + emitPostUpdated, + subscribeToPostEvents, +} from "@/services/postEvents"; +import { TagChip } from "@/components/TagChip"; +import { ThemedText } from "@/components/ThemedText"; +import { TopBlur } from "@/components/TopBlur"; +import { MarkdownText } from "@/components/MarkdownText"; +import { MediaGallery } from "@/components/MediaGallery"; +import { FloatingScrollTopButton } from "@/components/FloatingScrollTopButton"; +import { useAuth } from "@/contexts/AuthContext"; +import { useSaved } from "@/contexts/SavedContext"; +import { useAutoRefresh } from "@/hooks/useAutoRefresh"; +import { useAppColors } from "@/hooks/useAppColors"; +import { useMotionConfig } from "@/hooks/useMotionConfig"; +import { useTopBlurScroll } from "@/hooks/useTopBlurScroll"; +import * as DocumentPicker from "expo-document-picker"; +import * as ImagePicker from "expo-image-picker"; + +type CommentState = { + data: ApiComment; + author?: ApiUser | null; + liked: boolean; +}; + +const PostBody = React.memo(function PostBody({ + content, + media, +}: { + content: string; + media?: string[]; +}) { + return ( + <> + {content} + + + ); +}); + +export default function PostDetailScreen() { + const colors = useAppColors(); + const insets = useSafeAreaInsets(); + const router = useRouter(); + const { postId } = useLocalSearchParams<{ postId?: string }>(); + const { user } = useAuth(); + const { isSaved, savedPostIds, toggleSave } = useSaved(); + const motion = useMotionConfig(); + const reveal = useRef(new Animated.Value(0.08)).current; + const scrollRef = useRef(null); + const { scrollY, onScroll } = useTopBlurScroll(); + const [post, setPost] = useState(null); + const [project, setProject] = useState(null); + const [author, setAuthor] = useState(null); + const [comments, setComments] = useState([]); + const [content, setContent] = useState(""); + const [commentMedia, setCommentMedia] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isUploadingMedia, setIsUploadingMedia] = useState(false); + const [isEditingPost, setIsEditingPost] = useState(false); + const [postDraft, setPostDraft] = useState(""); + const [postEditMedia, setPostEditMedia] = useState([]); + const [isPostUpdating, setIsPostUpdating] = useState(false); + const [isPostDeleting, setIsPostDeleting] = useState(false); + const [isPostMediaUploading, setIsPostMediaUploading] = useState(false); + const [editingCommentId, setEditingCommentId] = useState(null); + const [editingCommentText, setEditingCommentText] = useState(""); + const [editingCommentMedia, setEditingCommentMedia] = useState([]); + const [isCommentUpdating, setIsCommentUpdating] = useState(false); + const [isCommentMediaUploading, setIsCommentMediaUploading] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + const [isRefreshing, setIsRefreshing] = useState(false); + const [postLiked, setPostLiked] = useState(false); + const [isPostLikeUpdating, setIsPostLikeUpdating] = useState(false); + const [postLikeCount, setPostLikeCount] = useState(0); + const [postSaveCount, setPostSaveCount] = useState(0); + const [postSaved, setPostSaved] = useState(false); + const [isPostSaveUpdating, setIsPostSaveUpdating] = useState(false); + const [isKeyboardVisible, setIsKeyboardVisible] = useState(false); + + const postIdNumber = useMemo(() => Number(postId), [postId]); + + const hasPendingInteraction = + isSubmitting || + isUploadingMedia || + isPostLikeUpdating || + isPostSaveUpdating || + isEditingPost || + isKeyboardVisible || + !!content.trim(); + + useEffect(() => { + const showSub = Keyboard.addListener("keyboardDidShow", () => { + setIsKeyboardVisible(true); + }); + const hideSub = Keyboard.addListener("keyboardDidHide", () => { + setIsKeyboardVisible(false); + }); + + return () => { + showSub.remove(); + hideSub.remove(); + }; + }, []); + + useEffect(() => { + if (motion.prefersReducedMotion) { + reveal.setValue(1); + return; + } + + Animated.timing(reveal, { + toValue: 1, + duration: motion.duration(420), + useNativeDriver: true, + }).start(); + }, [motion, reveal]); + + const loadPost = useCallback( + async (showLoader = true) => { + if (!postIdNumber) { + if (showLoader) { + setIsLoading(false); + } + return; + } + try { + if (showLoader) { + setIsLoading(true); + } + const postData = await getPostById(postIdNumber); + const [postAuthor, postProject, postComments, postLikeStatus] = + await Promise.all([ + getUserById(postData.user).catch(() => null), + getProjectById(postData.project).catch(() => null), + getCommentsByPostId(postData.id), + user?.username + ? isPostLiked(user.username, postData.id).catch(() => ({ + status: false, + })) + : Promise.resolve({ status: false }), + ]); + + const safeComments = Array.isArray(postComments) ? postComments : []; + + const commentStates = await Promise.all( + safeComments.map(async (comment) => { + const [commentAuthor, likedStatus] = await Promise.all([ + getUserById(comment.user).catch(() => null), + user?.username + ? isCommentLiked(user.username, comment.id).catch(() => ({ + status: false, + })) + : Promise.resolve({ status: false }), + ]); + return { + data: comment, + author: commentAuthor, + liked: likedStatus.status, + }; + }), + ); + + setPost((prev) => { + if ( + prev && + prev.id === postData.id && + prev.content === postData.content && + prev.user === postData.user && + prev.project === postData.project && + JSON.stringify(prev.media ?? []) === + JSON.stringify(postData.media ?? []) + ) { + return prev; + } + return postData; + }); + setAuthor(postAuthor); + setProject(postProject); + setComments(commentStates); + setPostLiked(postLikeStatus.status); + setPostLikeCount(postData.likes ?? 0); + setPostSaveCount(postData.saves ?? 0); + setErrorMessage(""); + } catch { + setErrorMessage("Unable to load post."); + } finally { + if (showLoader) { + setIsLoading(false); + } + } + }, + [postIdNumber, user?.username], + ); + + useEffect(() => { + loadPost(); + }, [loadPost]); + + useEffect(() => { + if (post && !isEditingPost) { + setPostDraft(post.content); + setPostEditMedia(post.media ?? []); + } + }, [isEditingPost, post]); + + useFocusEffect( + useCallback(() => { + clearApiCache(); + loadPost(false); + }, [loadPost]), + ); + + const handleRefresh = useCallback(async () => { + setIsRefreshing(true); + clearApiCache(); + await loadPost(false); + setIsRefreshing(false); + }, [loadPost]); + + useAutoRefresh( + () => { + if (hasPendingInteraction) { + return; + } + return loadPost(false); + }, + { focusRefresh: false }, + ); + + useEffect(() => { + if (!post?.id) { + return; + } + return subscribeToPostEvents((event) => { + if (event.postId !== post.id) { + return; + } + if (event.type === "updated") { + setPost((prev) => + prev + ? { + ...prev, + content: event.content, + media: event.media ?? prev.media, + } + : prev, + ); + } + if (event.type === "stats") { + if (typeof event.likes === "number") { + setPostLikeCount(event.likes); + } + if (typeof event.isLiked === "boolean") { + setPostLiked(event.isLiked); + } + } + if (event.type === "deleted") { + router.back(); + } + }); + }, [post?.id, router]); + + useEffect(() => { + if (!post?.id) { + setPostSaved(false); + return; + } + setPostSaved(isSaved(post.id)); + }, [isSaved, post?.id, savedPostIds]); + + const canEditPost = !!user?.id && !!post && post.user === user.id; + const handleOpenProfile = (username?: string | null) => { + if (!username) { + return; + } + router.push({ pathname: "/user/[username]", params: { username } }); + }; + + const handleOpenStream = () => { + if (!project?.id) { + return; + } + router.push({ + pathname: "/stream/[projectId]", + params: { projectId: String(project.id) }, + }); + }; + + const getMediaLabel = (url: string) => { + const trimmed = url.split("?")[0].split("#")[0]; + return trimmed.split("/").pop() || "attachment"; + }; + + const handleSubmit = async () => { + if (!user?.id || !post) { + return; + } + if (!content.trim()) { + setErrorMessage("Comment cannot be empty."); + return; + } + + setIsSubmitting(true); + setErrorMessage(""); + try { + await createCommentOnPost(post.id, { + user: user.id, + content: content.trim(), + parent_comment: null, + media: commentMedia, + }); + setContent(""); + setCommentMedia([]); + const refreshed = await getCommentsByPostId(post.id); + const safeComments = Array.isArray(refreshed) ? refreshed : []; + const commentStates = await Promise.all( + safeComments.map(async (comment) => { + const commentAuthor = await getUserById(comment.user).catch( + () => null, + ); + const likedStatus = user?.username + ? await isCommentLiked(user.username, comment.id).catch(() => ({ + status: false, + })) + : { status: false }; + return { + data: comment, + author: commentAuthor, + liked: likedStatus.status, + }; + }), + ); + setComments(commentStates); + emitPostStats(post.id, { comments: commentStates.length }); + } catch { + setErrorMessage("Failed to post comment."); + } finally { + setIsSubmitting(false); + } + }; + + const handleAddCommentMedia = async (source: "file" | "library") => { + if (isUploadingMedia) { + return; + } + setIsUploadingMedia(true); + setErrorMessage(""); + try { + let files: { uri: string; name: string; type: string }[] = []; + if (source === "library") { + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ["images", "videos"], + quality: 0.9, + }); + if (!result.canceled && result.assets?.length) { + files = result.assets.map((asset) => ({ + uri: asset.uri, + name: asset.fileName ?? `media-${Date.now()}`, + type: asset.type ?? "application/octet-stream", + })); + } + } else { + const result = await DocumentPicker.getDocumentAsync({ + type: "*/*", + copyToCacheDirectory: true, + }); + if (!result.canceled) { + files = [ + { + uri: result.assets[0].uri, + name: result.assets[0].name, + type: result.assets[0].mimeType ?? "application/octet-stream", + }, + ]; + } + } + + if (!files.length) { + return; + } + + const uploads = await Promise.all(files.map((file) => uploadMedia(file))); + const urls = uploads.map((item) => item.url); + setCommentMedia((prev) => [...prev, ...urls]); + } catch { + setErrorMessage("Upload failed. Try again."); + } finally { + setIsUploadingMedia(false); + } + }; + + const handleStartEditPost = () => { + if (!post) { + return; + } + setPostDraft(post.content); + setPostEditMedia(post.media ?? []); + setIsEditingPost(true); + setErrorMessage(""); + }; + + const handleCancelEditPost = () => { + if (post) { + setPostDraft(post.content); + setPostEditMedia(post.media ?? []); + } + setIsEditingPost(false); + }; + + const handleAddPostMedia = async (source: "file" | "library") => { + if (isPostMediaUploading) { + return; + } + setIsPostMediaUploading(true); + setErrorMessage(""); + try { + let files: { uri: string; name: string; type: string }[] = []; + if (source === "library") { + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ["images", "videos"], + quality: 0.9, + }); + if (!result.canceled && result.assets?.length) { + files = result.assets.map((asset) => ({ + uri: asset.uri, + name: asset.fileName ?? `media-${Date.now()}`, + type: asset.type ?? "application/octet-stream", + })); + } + } else { + const result = await DocumentPicker.getDocumentAsync({ + type: "*/*", + copyToCacheDirectory: true, + }); + if (!result.canceled) { + files = [ + { + uri: result.assets[0].uri, + name: result.assets[0].name, + type: result.assets[0].mimeType ?? "application/octet-stream", + }, + ]; + } + } + + if (!files.length) { + return; + } + + const uploads = await Promise.all(files.map((file) => uploadMedia(file))); + const urls = uploads.map((item) => item.url); + setPostEditMedia((prev) => [...prev, ...urls]); + } catch { + setErrorMessage("Upload failed. Try again."); + } finally { + setIsPostMediaUploading(false); + } + }; + + const handleRemovePostMedia = (url: string) => { + setPostEditMedia((prev) => prev.filter((item) => item !== url)); + }; + + const handleSavePost = async () => { + if (!post) { + return; + } + if (!postDraft.trim()) { + setErrorMessage("Post cannot be empty."); + return; + } + setIsPostUpdating(true); + setErrorMessage(""); + try { + const response = await updatePost(post.id, { + content: postDraft.trim(), + media: postEditMedia, + }); + const nextPost = response?.post ?? { + ...post, + content: postDraft.trim(), + media: postEditMedia, + }; + setPost(nextPost); + setIsEditingPost(false); + emitPostUpdated(nextPost.id, nextPost.content, nextPost.media ?? []); + } catch { + setErrorMessage("Failed to update post."); + } finally { + setIsPostUpdating(false); + } + }; + + const handleDeletePost = () => { + if (!post || isPostDeleting) { + return; + } + Alert.alert("Delete byte?", "This action cannot be undone.", [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: () => { + void (async () => { + setIsPostDeleting(true); + setErrorMessage(""); + try { + await deletePost(post.id); + emitPostDeleted(post.id); + router.back(); + } catch { + setErrorMessage("Failed to delete post."); + } finally { + setIsPostDeleting(false); + } + })(); + }, + }, + ]); + }; + + const handleTogglePostLike = async () => { + if (!post || !user?.username || isPostLikeUpdating) { + return; + } + + setIsPostLikeUpdating(true); + const previousLiked = postLiked; + const previousLikes = postLikeCount; + const nextLiked = !previousLiked; + const nextLikes = Math.max(0, previousLikes + (nextLiked ? 1 : -1)); + + setPostLiked(nextLiked); + setPostLikeCount(nextLikes); + emitPostStats(post.id, { likes: nextLikes, isLiked: nextLiked }); + + try { + if (nextLiked) { + await likePost(user.username, post.id); + } else { + await unlikePost(user.username, post.id); + } + + try { + const [serverStatus, serverPost] = await Promise.all([ + isPostLiked(user.username, post.id), + getPostById(post.id), + ]); + const normalizedLikes = Math.max(0, serverPost.likes ?? nextLikes); + setPostLiked(serverStatus.status); + setPostLikeCount(normalizedLikes); + emitPostStats(post.id, { + likes: normalizedLikes, + isLiked: serverStatus.status, + }); + } catch { + // Keep optimistic state if sync fails. + } + } catch { + setPostLiked(previousLiked); + setPostLikeCount(previousLikes); + emitPostStats(post.id, { likes: previousLikes, isLiked: previousLiked }); + } finally { + setIsPostLikeUpdating(false); + } + }; + + const handleTogglePostSave = async () => { + if (!post || isPostSaveUpdating) { + return; + } + setIsPostSaveUpdating(true); + const previousSaved = postSaved; + const previousSaves = postSaveCount; + const nextSaved = !previousSaved; + const nextSaves = Math.max(0, previousSaves + (nextSaved ? 1 : -1)); + + setPostSaved(nextSaved); + setPostSaveCount(nextSaves); + try { + await toggleSave(post.id); + } catch { + setPostSaved(previousSaved); + setPostSaveCount(previousSaves); + } finally { + setIsPostSaveUpdating(false); + } + }; + + const handleToggleLike = async (comment: CommentState) => { + if (!user?.username) { + return; + } + try { + if (comment.liked) { + await unlikeComment(user.username, comment.data.id); + } else { + await likeComment(user.username, comment.data.id); + } + setComments((prev) => + prev.map((item) => + item.data.id === comment.data.id + ? { + ...item, + liked: !item.liked, + data: { + ...item.data, + likes: item.liked + ? Math.max(0, item.data.likes - 1) + : item.data.likes + 1, + }, + } + : item, + ), + ); + } catch { + // keep current state + } + }; + + const handleStartEditComment = (comment: CommentState) => { + setEditingCommentId(comment.data.id); + setEditingCommentText(comment.data.content); + setEditingCommentMedia(comment.data.media ?? []); + }; + + const handleCancelEditComment = () => { + setEditingCommentId(null); + setEditingCommentText(""); + setEditingCommentMedia([]); + }; + + const handleAddEditCommentMedia = async (source: "file" | "library") => { + if (!editingCommentId || isCommentMediaUploading) { + return; + } + setIsCommentMediaUploading(true); + setErrorMessage(""); + try { + let files: { uri: string; name: string; type: string }[] = []; + if (source === "library") { + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ["images", "videos"], + quality: 0.9, + }); + if (!result.canceled && result.assets?.length) { + files = result.assets.map((asset) => ({ + uri: asset.uri, + name: asset.fileName ?? `media-${Date.now()}`, + type: asset.type ?? "application/octet-stream", + })); + } + } else { + const result = await DocumentPicker.getDocumentAsync({ + type: "*/*", + copyToCacheDirectory: true, + }); + if (!result.canceled) { + files = [ + { + uri: result.assets[0].uri, + name: result.assets[0].name, + type: result.assets[0].mimeType ?? "application/octet-stream", + }, + ]; + } + } + + if (!files.length) { + return; + } + + const uploads = await Promise.all(files.map((file) => uploadMedia(file))); + const urls = uploads.map((item) => item.url); + setEditingCommentMedia((prev) => [...prev, ...urls]); + } catch { + setErrorMessage("Upload failed. Try again."); + } finally { + setIsCommentMediaUploading(false); + } + }; + + const handleRemoveEditCommentMedia = (url: string) => { + setEditingCommentMedia((prev) => prev.filter((item) => item !== url)); + }; + + const handleSaveComment = async () => { + if (!editingCommentId) { + return; + } + if (!editingCommentText.trim()) { + setErrorMessage("Comment cannot be empty."); + return; + } + setIsCommentUpdating(true); + setErrorMessage(""); + try { + const response = await updateComment(editingCommentId, { + content: editingCommentText.trim(), + media: editingCommentMedia, + }); + const nextContent = + response?.comment?.content ?? editingCommentText.trim(); + setComments((prev) => + prev.map((item) => + item.data.id === editingCommentId + ? { + ...item, + data: { + ...item.data, + content: nextContent, + media: response?.comment?.media ?? editingCommentMedia, + }, + } + : item, + ), + ); + setEditingCommentId(null); + setEditingCommentText(""); + setEditingCommentMedia([]); + } catch { + setErrorMessage("Failed to update comment."); + } finally { + setIsCommentUpdating(false); + } + }; + + const handleDeleteComment = (commentId: number) => { + Alert.alert("Delete comment?", "This action cannot be undone.", [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: () => { + void (async () => { + setErrorMessage(""); + try { + await deleteComment(commentId); + setComments((prev) => { + const next = prev.filter((item) => item.data.id !== commentId); + if (post?.id) { + emitPostStats(post.id, { comments: next.length }); + } + return next; + }); + if (editingCommentId === commentId) { + handleCancelEditComment(); + } + } catch { + setErrorMessage("Failed to delete comment."); + } + })(); + }, + }, + ]); + }; + + return ( + + + + + + } + keyboardShouldPersistTaps="handled" + keyboardDismissMode="on-drag" + contentContainerStyle={[ + styles.container, + { + paddingTop: 8, + paddingBottom: 96 + insets.bottom, + }, + ]} + > + + {isLoading ? ( + + + + Loading post... + + + ) : post ? ( + <> + + + + handleOpenProfile(author?.username)} + style={styles.profileTapTarget} + > + + {author?.username ?? "Unknown"} + + + + + {project?.name ?? "Project"} + + + + + {project?.status !== undefined ? ( + + ) : null} + {canEditPost ? ( + + [ + styles.iconButton, + pressed && styles.iconButtonPressed, + ]} + disabled={isPostUpdating || isPostDeleting} + > + + + [ + styles.iconButton, + pressed && styles.iconButtonPressed, + ]} + disabled={isPostUpdating || isPostDeleting} + > + + + + ) : null} + + + {isEditingPost ? ( + + + {postDraft.trim() ? ( + + + Preview + + + {postDraft} + + + ) : null} + + + handleAddPostMedia("library")} + style={[ + styles.mediaButton, + { borderColor: colors.border }, + ]} + disabled={isPostMediaUploading} + > + + Add photo/video + + + handleAddPostMedia("file")} + style={[ + styles.mediaButton, + { borderColor: colors.border }, + ]} + disabled={isPostMediaUploading} + > + + Add file + + + + {isPostMediaUploading ? ( + + + + Uploading... + + + ) : null} + {postEditMedia.length ? ( + + {postEditMedia.map((item) => ( + handleRemovePostMedia(item)} + style={[ + styles.mediaChip, + { borderColor: colors.border }, + ]} + > + + {getMediaLabel(item)} × + + + ))} + + ) : null} + + + + + + Cancel + + + + {isPostUpdating ? ( + + ) : ( + + Save + + )} + + + + ) : ( + + )} + [ + styles.metaRow, + pressed && styles.metaPressed, + ]} + onPress={handleTogglePostLike} + disabled={isPostLikeUpdating} + > + + + {postLikeCount} likes + + + [ + styles.metaRow, + pressed && styles.metaPressed, + ]} + onPress={handleTogglePostSave} + disabled={isPostSaveUpdating} + > + + + {postSaveCount} saves + + + + + + + + + {isSubmitting ? ( + + ) : ( + + Send + + )} + + + + + handleAddCommentMedia("library")} + style={[ + styles.mediaButton, + { borderColor: colors.border }, + ]} + > + + Add photo/video + + + handleAddCommentMedia("file")} + style={[ + styles.mediaButton, + { borderColor: colors.border }, + ]} + > + + Add file + + + + {isUploadingMedia ? ( + + + + Uploading... + + + ) : null} + + + {content.trim() ? ( + + + Preview + + {content} + + ) : null} + {errorMessage ? ( + + {errorMessage} + + ) : null} + + + + Comments + {comments.length ? ( + comments.map((comment) => ( + + + + handleOpenProfile(comment.author?.username) + } + > + + {comment.author?.username ?? "Unknown"} + + + + handleToggleLike(comment)} + > + + + {comment.data.likes} + + + {comment.data.user === user?.id ? ( + + + handleStartEditComment(comment) + } + style={({ pressed }) => [ + styles.iconButton, + pressed && styles.iconButtonPressed, + ]} + > + + + + handleDeleteComment(comment.data.id) + } + style={({ pressed }) => [ + styles.iconButton, + pressed && styles.iconButtonPressed, + ]} + > + + + + ) : null} + + + {editingCommentId === comment.data.id ? ( + + + {editingCommentText.trim() ? ( + + + Preview + + + {editingCommentText} + + + ) : null} + + + + handleAddEditCommentMedia("library") + } + style={[ + styles.mediaButton, + { borderColor: colors.border }, + ]} + disabled={isCommentMediaUploading} + > + + Add photo/video + + + + handleAddEditCommentMedia("file") + } + style={[ + styles.mediaButton, + { borderColor: colors.border }, + ]} + disabled={isCommentMediaUploading} + > + + Add file + + + + {isCommentMediaUploading ? ( + + + + Uploading... + + + ) : null} + {editingCommentMedia.length ? ( + + {editingCommentMedia.map((item) => ( + + handleRemoveEditCommentMedia(item) + } + style={[ + styles.mediaChip, + { borderColor: colors.border }, + ]} + > + + {getMediaLabel(item)} × + + + ))} + + ) : null} + + + + + + Cancel + + + + {isCommentUpdating ? ( + + ) : ( + + Save + + )} + + + + ) : ( + <> + + {comment.data.content} + + + + )} + + )) + ) : ( + + + No comments yet. Start the thread. + + + )} + + + ) : ( + + + {errorMessage || "Post not found."} + + + )} + + + + + + scrollRef.current?.scrollTo({ y: 0, animated: true })} + /> + + + ); +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + }, + safeArea: { + flex: 1, + }, + container: { + paddingVertical: 16, + paddingHorizontal: 16, + gap: 16, + paddingTop: 0, + }, + loadingState: { + alignItems: "center", + paddingVertical: 24, + gap: 8, + }, + emptyState: { + alignItems: "center", + paddingVertical: 16, + }, + postCard: { + borderRadius: 16, + padding: 14, + borderWidth: 1, + gap: 12, + }, + postHeader: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + }, + postHeaderIdentity: { + gap: 2, + }, + profileTapTarget: { + alignSelf: "flex-start", + paddingVertical: 2, + paddingRight: 8, + }, + streamTapTarget: { + alignSelf: "flex-start", + paddingVertical: 6, + paddingRight: 10, + }, + postHeaderActions: { + flexDirection: "row", + alignItems: "center", + gap: 10, + }, + postActionRow: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + iconButton: { + width: 28, + height: 28, + borderRadius: 14, + alignItems: "center", + justifyContent: "center", + }, + iconButtonPressed: { + opacity: 0.7, + transform: [{ scale: 0.96 }], + }, + metaRow: { + flexDirection: "row", + alignItems: "center", + gap: 6, + }, + metaPressed: { + opacity: 0.85, + }, + commentSection: { + gap: 12, + paddingTop: 8, + }, + commentComposerBlock: { + gap: 12, + paddingTop: 12, + }, + commentCard: { + borderRadius: 12, + borderWidth: 1, + padding: 12, + gap: 8, + }, + commentHeader: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + }, + commentActions: { + flexDirection: "row", + alignItems: "center", + gap: 10, + }, + inlineActionRow: { + flexDirection: "row", + alignItems: "center", + gap: 6, + }, + likeButton: { + flexDirection: "row", + alignItems: "center", + gap: 6, + }, + editBlock: { + gap: 10, + }, + editInput: { + borderRadius: 10, + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 8, + fontFamily: "SpaceMono", + fontSize: 14, + }, + editActions: { + flexDirection: "row", + justifyContent: "flex-end", + gap: 10, + }, + editButton: { + borderRadius: 10, + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 6, + alignItems: "center", + minWidth: 72, + }, + composer: { + flexDirection: "row", + alignItems: "center", + gap: 10, + borderRadius: 12, + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 8, + }, + input: { + flex: 1, + fontFamily: "SpaceMono", + }, + submitButton: { + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 6, + }, + commentMediaSection: { + gap: 10, + paddingHorizontal: 2, + }, + commentMediaActions: { + flexDirection: "row", + gap: 10, + flexWrap: "wrap", + }, + mediaButton: { + borderRadius: 10, + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 8, + }, + uploadingRow: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + mediaChips: { + flexDirection: "row", + flexWrap: "wrap", + gap: 8, + }, + mediaChip: { + borderRadius: 10, + borderWidth: 1, + paddingHorizontal: 10, + paddingVertical: 6, + }, + previewBox: { + borderRadius: 10, + borderWidth: 1, + paddingHorizontal: 10, + paddingVertical: 8, + gap: 6, + }, +}); diff --git a/frontend/app/saved-library.tsx b/frontend/app/saved-library.tsx new file mode 100644 index 0000000..841dc31 --- /dev/null +++ b/frontend/app/saved-library.tsx @@ -0,0 +1,241 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { + Animated, + RefreshControl, + ScrollView, + StyleSheet, + View, +} from "react-native"; +import { + SafeAreaView, + useSafeAreaInsets, +} from "react-native-safe-area-context"; +import { useFocusEffect } from "@react-navigation/native"; +import { Post } from "@/components/Post"; +import { FloatingScrollTopButton } from "@/components/FloatingScrollTopButton"; +import { ThemedText } from "@/components/ThemedText"; +import { TopBlur } from "@/components/TopBlur"; +import { UnifiedLoadingList } from "@/components/UnifiedLoading"; +import { useAutoRefresh } from "@/hooks/useAutoRefresh"; +import { useAppColors } from "@/hooks/useAppColors"; +import { useMotionConfig } from "@/hooks/useMotionConfig"; +import { useTopBlurScroll } from "@/hooks/useTopBlurScroll"; +import { useSaved } from "@/contexts/SavedContext"; +import { + clearApiCache, + getPostById, + getProjectById, + getUserById, +} from "@/services/api"; +import { mapPostToUi } from "@/services/mappers"; +import { subscribeToPostEvents } from "@/services/postEvents"; + +export default function SavedLibraryScreen() { + const colors = useAppColors(); + const insets = useSafeAreaInsets(); + const { savedPostIds } = useSaved(); + const [posts, setPosts] = useState([] as ReturnType[]); + const [isLoading, setIsLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); + const scrollRef = useRef(null); + const motion = useMotionConfig(); + const reveal = useRef(new Animated.Value(0.08)).current; + const { scrollY, onScroll } = useTopBlurScroll(); + + useEffect(() => { + if (motion.prefersReducedMotion) { + reveal.setValue(1); + return; + } + + Animated.timing(reveal, { + toValue: 1, + duration: motion.duration(420), + useNativeDriver: true, + }).start(); + }, [motion, reveal]); + + const loadSaved = useCallback( + async (showLoader = true) => { + if (!savedPostIds.length) { + setPosts([]); + if (showLoader) { + setIsLoading(false); + } + return; + } + try { + if (showLoader) { + setIsLoading(true); + } + const savedData = await Promise.all( + savedPostIds.map(async (postId) => { + try { + const post = await getPostById(postId); + const [postUser, postProject] = await Promise.all([ + getUserById(post.user).catch(() => null), + getProjectById(post.project).catch(() => null), + ]); + return mapPostToUi(post, postUser, postProject); + } catch { + return null; + } + }), + ); + + setPosts( + savedData.filter( + (post): post is ReturnType => post !== null, + ), + ); + } finally { + if (showLoader) { + setIsLoading(false); + } + } + }, + [savedPostIds], + ); + + useEffect(() => { + loadSaved(); + }, [loadSaved]); + + useEffect(() => { + return subscribeToPostEvents((event) => { + setPosts((prev) => { + if (event.type === "updated") { + return prev.map((post) => + post.id === event.postId + ? { + ...post, + content: event.content, + media: event.media ?? post.media, + } + : post, + ); + } + if (event.type === "stats") { + return prev.map((post) => + post.id === event.postId + ? { + ...post, + likes: event.likes ?? post.likes, + comments: event.comments ?? post.comments, + } + : post, + ); + } + if (event.type === "deleted") { + return prev.filter((post) => post.id !== event.postId); + } + return prev; + }); + }); + }, []); + + useFocusEffect( + useCallback(() => { + clearApiCache(); + loadSaved(false); + }, [loadSaved]), + ); + + const handleRefresh = useCallback(async () => { + setIsRefreshing(true); + clearApiCache(); + await loadSaved(false); + setIsRefreshing(false); + }, [loadSaved]); + + useAutoRefresh(() => loadSaved(false), { focusRefresh: false }); + + return ( + + + + } + contentContainerStyle={[ + styles.container, + { paddingTop: 8, paddingBottom: 96 + insets.bottom }, + ]} + > + + + Saved library + + + Everything you bookmarked. + + + + {isLoading ? ( + + ) : posts.length ? ( + posts.map((post) => ) + ) : ( + + + No saved bytes yet. + + + )} + + + + scrollRef.current?.scrollTo({ y: 0, animated: true })} + bottomOffset={insets.bottom + 20} + /> + + ); +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + }, + safeArea: { + flex: 1, + }, + container: { + paddingVertical: 16, + paddingHorizontal: 16, + gap: 16, + paddingTop: 0, + }, + title: { + fontSize: 26, + lineHeight: 30, + }, + emptyState: { + alignItems: "center", + paddingVertical: 24, + }, +}); diff --git a/frontend/app/saved-streams.tsx b/frontend/app/saved-streams.tsx new file mode 100644 index 0000000..5a322e0 --- /dev/null +++ b/frontend/app/saved-streams.tsx @@ -0,0 +1,249 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { Animated, RefreshControl, StyleSheet, View } from "react-native"; +import { + SafeAreaView, + useSafeAreaInsets, +} from "react-native-safe-area-context"; +import { useFocusEffect } from "@react-navigation/native"; +import { ProjectCard } from "@/components/ProjectCard"; +import { FloatingScrollTopButton } from "@/components/FloatingScrollTopButton"; +import { ThemedText } from "@/components/ThemedText"; +import { TopBlur } from "@/components/TopBlur"; +import { useAutoRefresh } from "@/hooks/useAutoRefresh"; +import { useAppColors } from "@/hooks/useAppColors"; +import { useMotionConfig } from "@/hooks/useMotionConfig"; +import { useTopBlurScroll } from "@/hooks/useTopBlurScroll"; +import { useSavedStreams } from "@/contexts/SavedStreamsContext"; +import { + clearApiCache, + getProjectById, + getProjectBuilders, +} from "@/services/api"; +import { mapProjectToUi } from "@/services/mappers"; +import { + applyProjectEvent, + subscribeToProjectEvents, +} from "@/services/projectEvents"; + +export default function SavedStreamsScreen() { + const colors = useAppColors(); + const insets = useSafeAreaInsets(); + const { savedProjectIds, removeSavedProjectIds } = useSavedStreams(); + const [projects, setProjects] = useState( + [] as ReturnType[], + ); + const [isLoading, setIsLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); + const scrollRef = useRef(null); + const motion = useMotionConfig(); + const reveal = useRef(new Animated.Value(0.08)).current; + const { scrollY, onScroll } = useTopBlurScroll(); + + useEffect(() => { + if (motion.prefersReducedMotion) { + reveal.setValue(1); + return; + } + + Animated.timing(reveal, { + toValue: 1, + duration: motion.duration(420), + useNativeDriver: true, + }).start(); + }, [motion, reveal]); + + const loadSaved = useCallback( + async (showLoader = true) => { + if (!savedProjectIds.length) { + setProjects([]); + if (showLoader) { + setIsLoading(false); + } + return; + } + try { + if (showLoader) { + setIsLoading(true); + } + const projectsData = await Promise.all( + savedProjectIds.map((projectId) => + getProjectById(projectId).catch(() => null), + ), + ); + const missingIds = savedProjectIds.filter( + (_, index) => !projectsData[index], + ); + if (missingIds.length) { + void removeSavedProjectIds(missingIds); + } + const validProjects = projectsData.filter((project) => project); + const builderCounts = await Promise.all( + validProjects.map((project) => + getProjectBuilders(project!.id).catch(() => []), + ), + ); + const mapped = validProjects.map((project, index) => + mapProjectToUi(project!, builderCounts[index]?.length ?? 0), + ); + + setProjects(mapped); + } finally { + if (showLoader) { + setIsLoading(false); + } + } + }, + [removeSavedProjectIds, savedProjectIds], + ); + + useEffect(() => { + loadSaved(); + }, [loadSaved]); + + useEffect(() => { + return subscribeToProjectEvents((event) => { + setProjects((prev) => applyProjectEvent(prev, event)); + }); + }, []); + + useFocusEffect( + useCallback(() => { + clearApiCache(); + loadSaved(false); + }, [loadSaved]), + ); + + const handleRefresh = useCallback(async () => { + setIsRefreshing(true); + clearApiCache(); + await loadSaved(false); + setIsRefreshing(false); + }, [loadSaved]); + + useAutoRefresh(() => loadSaved(false), { focusRefresh: false }); + + return ( + + + + } + contentContainerStyle={[ + styles.container, + { paddingTop: 8, paddingBottom: 96 + insets.bottom }, + ]} + > + + + Saved streams + + + Your saved stream library. + + + + {isLoading ? ( + + {[0, 1, 2].map((key) => ( + + ))} + + ) : projects.length ? ( + projects.map((project) => ( + { + if (!nextSaved) { + setProjects((prev) => + prev.filter((item) => item.id !== project.id), + ); + } + }} + /> + )) + ) : ( + + + No saved streams yet. + + + )} + + + + scrollRef.current?.scrollTo({ y: 0, animated: true })} + bottomOffset={insets.bottom + 20} + /> + + ); +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + }, + safeArea: { + flex: 1, + }, + container: { + paddingVertical: 16, + paddingHorizontal: 16, + gap: 16, + paddingTop: 0, + }, + title: { + fontSize: 26, + lineHeight: 30, + }, + skeletonStack: { + gap: 12, + }, + skeletonCard: { + borderRadius: 14, + height: 120, + borderWidth: 1, + opacity: 0.7, + }, + emptyState: { + alignItems: "center", + paddingVertical: 24, + }, +}); diff --git a/frontend/app/settings/_layout.tsx b/frontend/app/settings/_layout.tsx new file mode 100644 index 0000000..5d1810d --- /dev/null +++ b/frontend/app/settings/_layout.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import { Stack } from "expo-router"; + +export default function SettingsLayout() { + return ( + + ); +} diff --git a/frontend/app/settings/about.tsx b/frontend/app/settings/about.tsx new file mode 100644 index 0000000..0fdcb4f --- /dev/null +++ b/frontend/app/settings/about.tsx @@ -0,0 +1,106 @@ +import React from "react"; +import { Pressable, View } from "react-native"; +import Constants from "expo-constants"; +import { openBrowserAsync } from "expo-web-browser"; +import { ThemedText } from "@/components/ThemedText"; +import { useAuth } from "@/contexts/AuthContext"; +import { useAppColors } from "@/hooks/useAppColors"; +import { SettingsPageShell, settingsStyles } from "@/features/settings/shared"; + +const SITE_BASE_URL = ( + process.env.EXPO_PUBLIC_SITE_URL?.trim() || "https://devbits.ddns.net" +).replace(/\/+$/, ""); + +const publicLinks = [ + { + label: "Privacy policy", + detail: "Open public privacy policy page", + href: `${SITE_BASE_URL}/privacy-policy`, + }, + { + label: "Account deletion", + detail: "Open account deletion instructions", + href: `${SITE_BASE_URL}/account-deletion`, + }, +]; + +export default function SettingsAboutScreen() { + const colors = useAppColors(); + const { user } = useAuth(); + + const appVersion = + Constants.expoConfig?.version ?? + Constants.manifest2?.extra?.expoClient?.version ?? + "Unknown"; + + const runtimeVersion = + (typeof Constants.expoConfig?.runtimeVersion === "string" + ? Constants.expoConfig.runtimeVersion + : null) || "default"; + + return ( + + + App + + + + + + + + Public pages + {publicLinks.map((item) => ( + void openBrowserAsync(item.href)} + style={({ pressed }) => [ + settingsStyles.buttonAlt, + { + borderColor: colors.border, + backgroundColor: colors.surfaceAlt, + opacity: pressed ? 0.82 : 1, + }, + ]} + > + + {item.label} + + {item.detail} + + + + ))} + + + + Base URL: {SITE_BASE_URL} + + + ); +} + +function InfoRow({ label, value }: { label: string; value: string }) { + const colors = useAppColors(); + return ( + + + {label} + + {value} + + ); +} diff --git a/frontend/app/settings/bio.tsx b/frontend/app/settings/bio.tsx new file mode 100644 index 0000000..07d8fed --- /dev/null +++ b/frontend/app/settings/bio.tsx @@ -0,0 +1,662 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + ActivityIndicator, + Pressable, + StyleSheet, + TextInput, + View, +} from "react-native"; +import * as ImagePicker from "expo-image-picker"; +import { manipulateAsync, SaveFormat } from "expo-image-manipulator"; +import { File as ExpoFile } from "expo-file-system"; +import { FadeInImage } from "@/components/FadeInImage"; +import { MarkdownText } from "@/components/MarkdownText"; +import { ThemedText } from "@/components/ThemedText"; +import { useAuth } from "@/contexts/AuthContext"; +import { useAppColors } from "@/hooks/useAppColors"; +import { + invalidateCachedUserById, + getMe, + resolveMediaUrl, + uploadMedia, + upsertCachedUser, + updateUser, +} from "@/services/api"; +import { SettingsPageShell, settingsStyles } from "@/features/settings/shared"; +import { buildLinks, parseLinks } from "@/features/settings/utils"; + +type ProfileDraft = { + picture: string; + bio: string; + website: string; + github: string; + twitter: string; + linkedin: string; + extraLinks: string; +}; + +type PendingPicture = { + uri: string; + dataUri: string; + name: string; + type: string; +}; + +export default function SettingsBioScreen() { + const colors = useAppColors(); + const { user, setUserDirect } = useAuth(); + + const [draft, setDraft] = useState({ + picture: user?.picture ?? "", + bio: user?.bio ?? "", + website: "", + github: "", + twitter: "", + linkedin: "", + extraLinks: "", + }); + + const [pendingPicture, setPendingPicture] = useState( + null, + ); + const [isPickingImage, setIsPickingImage] = useState(false); + const [hasMediaPermission, setHasMediaPermission] = useState( + null, + ); + const [message, setMessage] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isDirty, setIsDirty] = useState(false); + + const isMountedRef = useRef(true); + + useEffect(() => { + return () => { + isMountedRef.current = false; + }; + }, []); + + const initialDraft = useMemo(() => { + const parsed = parseLinks(user?.links ?? []); + return { + picture: user?.picture ?? "", + bio: user?.bio ?? "", + website: parsed.website, + github: parsed.github, + twitter: parsed.twitter, + linkedin: parsed.linkedin, + extraLinks: parsed.extraLinks.join(", "), + }; + }, [user]); + + useEffect(() => { + setDraft(initialDraft); + setIsDirty(false); + setPendingPicture(null); + }, [initialDraft]); + + useEffect(() => { + const dirty = + pendingPicture !== null || + draft.picture !== initialDraft.picture || + draft.bio !== initialDraft.bio || + draft.website !== initialDraft.website || + draft.github !== initialDraft.github || + draft.twitter !== initialDraft.twitter || + draft.linkedin !== initialDraft.linkedin || + draft.extraLinks !== initialDraft.extraLinks; + setIsDirty(dirty); + }, [draft, initialDraft, pendingPicture]); + + const updateDraft = (updates: Partial) => { + setDraft((prev) => ({ ...prev, ...updates })); + }; + + useEffect(() => { + let mounted = true; + + ImagePicker.getMediaLibraryPermissionsAsync() + .then((permission) => { + if (!mounted) return; + setHasMediaPermission( + permission.granted || permission.accessPrivileges === "limited", + ); + }) + .catch(() => { + if (mounted) setHasMediaPermission(null); + }); + + return () => { + mounted = false; + }; + }, []); + + const handlePickImage = async () => { + if (isPickingImage) return; + + setIsPickingImage(true); + setMessage(""); + + try { + let hasPermission = hasMediaPermission; + if (!hasPermission) { + const permission = + await ImagePicker.requestMediaLibraryPermissionsAsync(); + hasPermission = + permission.granted || permission.accessPrivileges === "limited"; + setHasMediaPermission(hasPermission); + } + + if (!hasPermission) { + setMessage("Photo access is required to pick an image."); + return; + } + + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ["images"], + allowsEditing: true, + aspect: [1, 1], + quality: 0.6, + exif: false, + }); + + if (!result.canceled && result.assets?.length) { + const asset = result.assets[0]; + + // 400x400 looks sharp on retina screens while keeping the + // base64 data URI small enough (~20-40 KB) for reliable + // JSON PUT on iOS. + const MAX_PROFILE_PIC_SIZE = 400; + + const manipulated = await manipulateAsync( + asset.uri, + [ + { + resize: { + width: MAX_PROFILE_PIC_SIZE, + height: MAX_PROFILE_PIC_SIZE, + }, + }, + ], + { compress: 0.65, format: SaveFormat.JPEG }, + ); + + const fileName = `profile-${Date.now()}.jpg`; + const mimeType = "image/jpeg"; + + // Read the manipulated file as base64 immediately (while the + // temp file is guaranteed to exist) so we can send it as a + // data URI through the normal JSON update endpoint, avoiding + // the unreliable multipart FormData upload on iOS. + const base64 = await new ExpoFile(manipulated.uri).base64(); + const dataUri = `data:${mimeType};base64,${base64}`; + + setPendingPicture({ + uri: manipulated.uri, + dataUri, + name: fileName, + type: mimeType, + }); + updateDraft({ picture: manipulated.uri }); + setIsDirty(true); + } + } catch (error) { + setMessage( + error instanceof Error ? error.message : "Failed to pick image.", + ); + } finally { + setIsPickingImage(false); + } + }; + + const getActiveUsername = useCallback(async () => { + return user?.username?.trim() || (await getMe())?.username?.trim() || ""; + }, [user?.username]); + + const onSaveSuccess = useCallback(() => { + if (isMountedRef.current) { + setPendingPicture(null); + setMessage("Profile updated successfully."); + setTimeout(() => { + if (isMountedRef.current) { + setMessage(""); + } + }, 3000); + } + }, []); + + const handleSave = async () => { + if (isSubmitting || isPickingImage || !isDirty) return; + + setIsSubmitting(true); + setMessage(""); + + try { + const username = await getActiveUsername(); + if (!username) { + setMessage("You must be signed in to update your profile."); + return; + } + + // Build the update payload — bio, links, and (optionally) picture. + const links = buildLinks({ + website: draft.website, + github: draft.github, + twitter: draft.twitter, + linkedin: draft.linkedin, + extraLinks: draft.extraLinks, + }); + + const profilePayload: { bio: string; links: string[]; picture?: string } = + { + bio: draft.bio, + links, + }; + + // If a new picture was picked, try the fast path first: + // upload via POST /media/upload (multipart POST is reliable on + // iOS) and send the resulting URL in the profile PUT. The PUT + // body stays tiny (~200 B) so iOS CFNetwork won't drop it. + // Only fall back to the large base64 data URI approach if the + // multipart upload fails. + if (pendingPicture) { + try { + const uploaded = await uploadMedia({ + uri: pendingPicture.uri, + name: pendingPicture.name, + type: pendingPicture.type, + }); + // uploaded.url is already resolved to an absolute URL; + // the backend's materializeMediaReference will normalise + // it back to a relative /uploads/ path. + profilePayload.picture = uploaded.url; + } catch { + // Multipart upload failed — fall back to data URI + profilePayload.picture = pendingPicture.dataUri; + } + } else if (draft.picture !== initialDraft.picture) { + profilePayload.picture = draft.picture; + } + + // iOS CFNetwork sometimes silently drops the request body. + // Retry up to 6 times with a short delay. + const MAX_RETRIES = 6; + let lastError: unknown; + let response: Awaited> | null = null; + + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + response = await updateUser(username, profilePayload); + break; // success + } catch (err) { + lastError = err; + if (attempt < MAX_RETRIES) { + await new Promise((r) => setTimeout(r, 250)); + } + } + } + + if (!response) { + throw lastError instanceof Error + ? lastError + : new Error("Profile update failed after multiple attempts."); + } + + const latestUser = response?.user; + + if (latestUser) { + upsertCachedUser(latestUser); + if (typeof latestUser.id === "number") { + invalidateCachedUserById(latestUser.id); + } + // Update the AuthContext user immediately from the PUT response + // instead of making a redundant GET /auth/me round-trip. + setUserDirect(latestUser); + onSaveSuccess(); + } else { + throw new Error("Failed to get updated user from server."); + } + } catch (error) { + setMessage( + error instanceof Error ? error.message : "An unknown error occurred.", + ); + } finally { + if (isMountedRef.current) { + setIsSubmitting(false); + } + } + }; + + const handleRemovePicture = () => { + if (isSubmitting) return; + updateDraft({ picture: "" }); + setPendingPicture(null); + }; + + const resetDraft = useCallback(() => { + setDraft(initialDraft); + setPendingPicture(null); + setMessage(""); + setIsDirty(false); + }, [initialDraft]); + + const resolvedPicture = useMemo(() => { + // When a pending local image is selected (file:// or content:// URI), + // use it directly — resolveMediaUrl is for server paths only. + if (pendingPicture) { + return pendingPicture.uri; + } + return resolveMediaUrl(draft.picture); + }, [draft.picture, pendingPicture]); + + return ( + + [ + settingsStyles.button, + styles.topSaveButton, + { + backgroundColor: + !isDirty || isSubmitting || isPickingImage + ? colors.surfaceAlt + : colors.tint, + }, + pressed && isDirty && !isSubmitting && styles.pressFeedback, + ]} + disabled={!isDirty || isSubmitting || isPickingImage} + > + {isSubmitting ? ( + + ) : ( + + Save + + )} + + {isDirty ? ( + [ + settingsStyles.buttonAlt, + styles.cancelButton, + { borderColor: colors.border }, + pressed && styles.pressFeedback, + ]} + > + + Cancel + + + ) : null} + + } + > + + + {resolvedPicture ? ( + + ) : ( + + Add photo + + )} + + + [ + settingsStyles.buttonAlt, + styles.chooseImageButton, + { borderColor: colors.border }, + pressed && styles.pressFeedback, + ]} + disabled={isPickingImage || isSubmitting} + > + {isPickingImage ? ( + + ) : ( + + Choose image + + )} + + {draft.picture.trim() ? ( + [ + settingsStyles.buttonAlt, + styles.removeImageButton, + { borderColor: colors.border }, + pressed && styles.pressFeedback, + ]} + disabled={isSubmitting} + > + + Remove photo + + + ) : null} + + + + + { + // If the user pasted Markdown like `![alt](/path)` or an tag, + // extract the actual URL so the profile image field isn't set to raw MD. + const mdMatch = value.match(/!\[[^\]]*\]\(([^)]+)\)/); + const imgTagMatch = value.match( + /]*src=["']([^"']+)["'][^>]*>/i, + ); + const normalized = mdMatch + ? mdMatch[1] + : imgTagMatch + ? imgTagMatch[1] + : value; + updateDraft({ picture: normalized }); + setPendingPicture(null); + }} + /> + updateDraft({ bio: value })} + multiline + /> + {draft.bio.trim() ? ( + + + Bio preview + + {draft.bio} + + ) : null} + updateDraft({ website: value })} + /> + updateDraft({ github: value })} + /> + updateDraft({ twitter: value })} + /> + updateDraft({ linkedin: value })} + /> + updateDraft({ extraLinks: value })} + /> + + {message ? ( + + {message} + + ) : null} + + + ); +} + +function Field({ + label, + value, + onChange, + multiline, + onBlur, +}: { + label: string; + value: string; + onChange: (value: string) => void; + multiline?: boolean; + onBlur?: () => void; +}) { + const colors = useAppColors(); + + return ( + + + {label} + + + + + + ); +} + +const styles = StyleSheet.create({ + avatarCard: { + borderWidth: 1, + borderRadius: 14, + padding: 12, + gap: 10, + alignItems: "flex-start", + }, + avatar: { + width: 72, + height: 72, + borderRadius: 36, + borderWidth: 1, + alignItems: "center", + justifyContent: "center", + overflow: "hidden", + }, + avatarImage: { + width: "100%", + height: "100%", + }, + chooseImageButton: { + paddingHorizontal: 14, + paddingVertical: 10, + minHeight: 42, + }, + removeImageButton: { + paddingHorizontal: 14, + paddingVertical: 10, + minHeight: 42, + }, + imageActionRow: { + flexDirection: "row", + alignItems: "center", + gap: 8, + flexWrap: "wrap", + }, + cancelButton: { + minHeight: 32, + paddingVertical: 6, + paddingHorizontal: 10, + borderRadius: 10, + }, + topSaveButton: { + minHeight: 32, + paddingVertical: 6, + paddingHorizontal: 12, + borderRadius: 10, + }, + headerActionRow: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + pressFeedback: { + opacity: 0.82, + transform: [{ scale: 0.985 }], + }, + bioPreview: { + gap: 6, + }, +}); diff --git a/frontend/app/settings/help-navigation.tsx b/frontend/app/settings/help-navigation.tsx new file mode 100644 index 0000000..8a22987 --- /dev/null +++ b/frontend/app/settings/help-navigation.tsx @@ -0,0 +1,106 @@ +import React from "react"; +import { Pressable, View } from "react-native"; +import { useRouter } from "expo-router"; +import { ThemedText } from "@/components/ThemedText"; +import { useAuth } from "@/contexts/AuthContext"; +import { useAppColors } from "@/hooks/useAppColors"; +import { SettingsPageShell, settingsStyles } from "@/features/settings/shared"; + +export default function SettingsHelpNavigationScreen() { + const colors = useAppColors(); + const router = useRouter(); + const { user } = useAuth(); + + return ( + + + Guides + + router.push({ pathname: "/welcome", params: { mode: "help" } }) + } + /> + router.push("/markdown-help")} + /> + + + + Quick navigation + router.push("/notifications")} + /> + router.push("/terminal")} + /> + { + if (!user?.username) { + return; + } + router.push({ + pathname: "/user/[username]", + params: { username: user.username }, + }); + }} + /> + + + ); +} + +function ActionRow({ + label, + detail, + onPress, +}: { + label: string; + detail: string; + onPress: () => void; +}) { + const colors = useAppColors(); + + return ( + [ + settingsStyles.buttonAlt, + { + borderColor: colors.border, + backgroundColor: colors.surfaceAlt, + opacity: pressed ? 0.82 : 1, + }, + ]} + > + + {label} + + {detail} + + + + ); +} diff --git a/frontend/app/settings/index.tsx b/frontend/app/settings/index.tsx new file mode 100644 index 0000000..05341ba --- /dev/null +++ b/frontend/app/settings/index.tsx @@ -0,0 +1,160 @@ +import React, { useEffect, useRef } from "react"; +import { Animated, StyleSheet, View } from "react-native"; +import { + SafeAreaView, + useSafeAreaInsets, +} from "react-native-safe-area-context"; +import { useRouter } from "expo-router"; +import { FloatingScrollTopButton } from "@/components/FloatingScrollTopButton"; +import { ThemedText } from "@/components/ThemedText"; +import { TopBlur } from "@/components/TopBlur"; +import { useAuth } from "@/contexts/AuthContext"; +import { useAppColors } from "@/hooks/useAppColors"; +import { useMotionConfig } from "@/hooks/useMotionConfig"; +import { useTopBlurScroll } from "@/hooks/useTopBlurScroll"; +import { SettingsCard } from "@/features/settings/shared"; + +export default function SettingsHubScreen() { + const colors = useAppColors(); + const insets = useSafeAreaInsets(); + const router = useRouter(); + const motion = useMotionConfig(); + const reveal = useRef(new Animated.Value(0.08)).current; + const scrollRef = useRef(null); + const { scrollY, onScroll } = useTopBlurScroll(); + const { user } = useAuth(); + + useEffect(() => { + if (motion.prefersReducedMotion) { + reveal.setValue(1); + return; + } + + Animated.timing(reveal, { + toValue: 1, + duration: motion.duration(340), + useNativeDriver: true, + }).start(); + }, [motion, reveal]); + + return ( + + + + + + Settings + + Organized, fast controls for your DevBits account. + + + + + router.push("/settings/bio")} + /> + router.push("/settings/system")} + /> + router.push("/settings/theme")} + /> + router.push("/settings/help-navigation")} + /> + router.push("/settings/security")} + /> + router.push("/settings/media")} + /> + router.push("/settings/about")} + /> + + + + Signed in as + + {user?.username || "Anonymous"} + + + + + + + scrollRef.current?.scrollTo({ y: 0, animated: true })} + bottomOffset={insets.bottom + 20} + /> + + ); +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + }, + content: { + paddingHorizontal: 16, + paddingTop: 12, + gap: 14, + }, + header: { + gap: 4, + }, + list: { + marginTop: 10, + gap: 10, + }, + identityCard: { + marginTop: 6, + borderWidth: 1, + borderRadius: 14, + padding: 12, + gap: 4, + }, +}); diff --git a/frontend/app/settings/media.tsx b/frontend/app/settings/media.tsx new file mode 100644 index 0000000..c168c6d --- /dev/null +++ b/frontend/app/settings/media.tsx @@ -0,0 +1,282 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Alert, Animated, Pressable, StyleSheet, View } from "react-native"; +import { + SafeAreaView, + useSafeAreaInsets, +} from "react-native-safe-area-context"; +import { useRouter } from "expo-router"; + +import { ThemedText } from "@/components/ThemedText"; +import { TopBlur } from "@/components/TopBlur"; +import { useAuth } from "@/contexts/AuthContext"; +import { useAppColors } from "@/hooks/useAppColors"; +import { useTopBlurScroll } from "@/hooks/useTopBlurScroll"; +import { + deleteMyManagedMedia, + getMyManagedMedia, + ManagedMediaItem, + resolveMediaUrl, +} from "@/services/api"; +import { FadeInImage } from "@/components/FadeInImage"; + +const isImageFile = (filename: string) => + /\.(png|jpe?g|gif|webp|heic|bmp|svg)$/i.test(filename); + +export default function SettingsMediaScreen() { + const colors = useAppColors(); + const insets = useSafeAreaInsets(); + const router = useRouter(); + const { user } = useAuth(); + const { scrollY, onScroll } = useTopBlurScroll(); + + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [busyFile, setBusyFile] = useState(null); + const [message, setMessage] = useState(""); + + const username = user?.username ?? ""; + + const load = useCallback(async () => { + if (!username) { + return; + } + setLoading(true); + setMessage(""); + try { + const response = await getMyManagedMedia(username); + setItems(response.items ?? []); + } catch (error) { + setMessage( + error instanceof Error ? error.message : "Failed to load media.", + ); + } finally { + setLoading(false); + } + }, [username]); + + useEffect(() => { + void load(); + }, [load]); + + const deleteOne = useCallback( + async (filename: string) => { + if (!username) { + return; + } + setBusyFile(filename); + setMessage(""); + try { + await deleteMyManagedMedia(username, { filenames: [filename] }); + setItems((prev) => prev.filter((item) => item.filename !== filename)); + } catch (error) { + setMessage( + error instanceof Error ? error.message : "Failed to delete media.", + ); + } finally { + setBusyFile(null); + } + }, + [username], + ); + + const deleteAll = useCallback(() => { + if (!username || !items.length) { + return; + } + Alert.alert( + "Delete all media?", + "This removes all uploaded media and files from your account.", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete all", + style: "destructive", + onPress: async () => { + setBusyFile("__all__"); + setMessage(""); + try { + await deleteMyManagedMedia(username, { deleteAll: true }); + setItems([]); + } catch (error) { + setMessage( + error instanceof Error + ? error.message + : "Failed to delete all media.", + ); + } finally { + setBusyFile(null); + } + }, + }, + ], + ); + }, [items.length, username]); + + const header = useMemo( + () => ( + + + router.back()} + style={[styles.backBtn, { borderColor: colors.border }]} + > + Back + + + + Delete All + + + + + Your Media & Files + + Includes uploaded photos, videos, files, and profile picture files. + + + {message ? ( + + {message} + + ) : null} + + ), + [ + busyFile, + colors.border, + colors.muted, + deleteAll, + items.length, + message, + router, + ], + ); + + return ( + + + item.filename} + onScroll={onScroll} + scrollEventThrottle={16} + contentContainerStyle={{ + paddingHorizontal: 16, + paddingBottom: insets.bottom + 28, + gap: 12, + }} + ListHeaderComponent={header} + ListEmptyComponent={ + + + {loading ? "Loading media..." : "No uploaded media found."} + + + } + renderItem={({ item }) => { + const image = isImageFile(item.filename); + const resolved = resolveMediaUrl(item.url); + const deleting = + busyFile === item.filename || busyFile === "__all__"; + + return ( + + + + + {item.filename} + + + {item.url} + + + deleteOne(item.filename)} + disabled={deleting} + style={[ + styles.deleteBtn, + { + borderColor: colors.border, + opacity: deleting ? 0.5 : 1, + }, + ]} + > + + Delete + + + + {image ? ( + + ) : null} + + ); + }} + /> + + + + ); +} + +const styles = StyleSheet.create({ + screen: { flex: 1 }, + headerWrap: { paddingTop: 8, gap: 8, marginBottom: 12 }, + headerRow: { flexDirection: "row", justifyContent: "space-between", gap: 10 }, + backBtn: { + borderWidth: 1, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 8, + }, + deleteAllBtn: { + borderWidth: 1, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 8, + }, + card: { borderWidth: 1, borderRadius: 14, padding: 10, gap: 10 }, + cardTop: { flexDirection: "row", alignItems: "center", gap: 10 }, + deleteBtn: { + borderWidth: 1, + borderRadius: 8, + paddingHorizontal: 10, + paddingVertical: 6, + }, + preview: { width: "100%", height: 160, borderRadius: 10 }, + empty: { borderWidth: 1, borderRadius: 12, padding: 12 }, +}); diff --git a/frontend/app/settings/security.tsx b/frontend/app/settings/security.tsx new file mode 100644 index 0000000..a4dad5c --- /dev/null +++ b/frontend/app/settings/security.tsx @@ -0,0 +1,137 @@ +import React, { useState } from "react"; +import { ActivityIndicator, Alert, Pressable, View } from "react-native"; +import { ThemedText } from "@/components/ThemedText"; +import { useAuth } from "@/contexts/AuthContext"; +import { useAppColors } from "@/hooks/useAppColors"; +import { deleteUser } from "@/services/api"; +import { SettingsPageShell, settingsStyles } from "@/features/settings/shared"; + +export default function SettingsSecurityScreen() { + const colors = useAppColors(); + const { user, signOut } = useAuth(); + const [isSigningOut, setIsSigningOut] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [message, setMessage] = useState(""); + + const handleSignOut = async () => { + if (isSigningOut) { + return; + } + setIsSigningOut(true); + setMessage(""); + try { + await signOut(); + } catch { + setMessage("Sign out failed. Try again."); + } finally { + setIsSigningOut(false); + } + }; + + const handleDeleteAccount = () => { + if (!user?.username || isDeleting) { + return; + } + + Alert.alert( + "Delete account?", + "This permanently deletes your account, streams, bytes, and history.", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: () => { + void (async () => { + setIsDeleting(true); + setMessage(""); + try { + await deleteUser(user.username); + await signOut(); + } catch { + setMessage("Account deletion failed. Try again."); + } finally { + setIsDeleting(false); + } + })(); + }, + }, + ], + ); + }; + + return ( + + + Session + [ + settingsStyles.buttonAlt, + { + borderColor: colors.border, + backgroundColor: colors.surfaceAlt, + opacity: pressed ? 0.82 : 1, + }, + ]} + disabled={isSigningOut} + > + {isSigningOut ? ( + + ) : ( + + Sign out + + )} + + + + + Danger zone + + This permanently removes your account and all related content. + + [ + settingsStyles.button, + { + backgroundColor: colors.surfaceAlt, + borderColor: colors.border, + borderWidth: 1, + opacity: pressed ? 0.78 : 1, + }, + ]} + disabled={isDeleting} + > + {isDeleting ? ( + + ) : ( + + Delete account + + )} + + + + {message ? ( + + {message} + + ) : null} + + ); +} diff --git a/frontend/app/settings/system.tsx b/frontend/app/settings/system.tsx new file mode 100644 index 0000000..bac1894 --- /dev/null +++ b/frontend/app/settings/system.tsx @@ -0,0 +1,230 @@ +import React from "react"; +import { Pressable, View } from "react-native"; +import { useRouter } from "expo-router"; +import { ThemedText } from "@/components/ThemedText"; +import { usePreferences } from "@/contexts/PreferencesContext"; +import { useAppColors } from "@/hooks/useAppColors"; +import { + LabeledSwitchRow, + SettingsPageShell, + settingsStyles, +} from "@/features/settings/shared"; +import { + pageTransitionOptions, + imageRevealOptions, + intervalOptions, + textRenderOptions, +} from "@/features/settings/utils"; + +export default function SettingsSystemScreen() { + const colors = useAppColors(); + const router = useRouter(); + const { preferences, updatePreferences } = usePreferences(); + + return ( + + + + void updatePreferences({ backgroundRefreshEnabled: value }) + } + /> + + void updatePreferences({ + linkOpenMode: value ? "promptScheme" : "asTyped", + }) + } + /> + void updatePreferences({ zenMode: value })} + /> + void updatePreferences({ compactMode: value })} + /> + + + + Refresh interval + + {intervalOptions.map((option) => { + const active = preferences.refreshIntervalMs === option.value; + return ( + + void updatePreferences({ refreshIntervalMs: option.value }) + } + disabled={!preferences.backgroundRefreshEnabled} + style={({ pressed }) => [ + settingsStyles.chip, + { + borderColor: colors.border, + backgroundColor: active ? colors.tint : colors.surfaceAlt, + opacity: preferences.backgroundRefreshEnabled ? 1 : 0.5, + }, + pressed && { opacity: 0.82 }, + ]} + > + + {option.label} + + + ); + })} + + + + + Text render effect + + {textRenderOptions.map((option) => { + const active = preferences.textRenderEffect === option.value; + return ( + + void updatePreferences({ textRenderEffect: option.value }) + } + style={({ pressed }) => [ + settingsStyles.chip, + { + borderColor: colors.border, + backgroundColor: active ? colors.tint : colors.surfaceAlt, + }, + pressed && { opacity: 0.82 }, + ]} + > + + {option.label} + + + ); + })} + + + + + Page transition + + {pageTransitionOptions.map((option) => { + const active = preferences.pageTransitionEffect === option.value; + return ( + + void updatePreferences({ pageTransitionEffect: option.value }) + } + style={({ pressed }) => [ + settingsStyles.chip, + { + borderColor: colors.border, + backgroundColor: active ? colors.tint : colors.surfaceAlt, + }, + pressed && { opacity: 0.82 }, + ]} + > + + {option.label} + + + ); + })} + + + + + Image reveal effect + + {imageRevealOptions.map((option) => { + const active = preferences.imageRevealEffect === option.value; + return ( + + void updatePreferences({ imageRevealEffect: option.value }) + } + style={({ pressed }) => [ + settingsStyles.chip, + { + borderColor: colors.border, + backgroundColor: active ? colors.tint : colors.surfaceAlt, + }, + pressed && { opacity: 0.82 }, + ]} + > + + {option.label} + + + ); + })} + + + + router.push("/settings/theme")} + style={({ pressed }) => [ + settingsStyles.buttonAlt, + { + borderColor: colors.border, + backgroundColor: colors.surface, + }, + pressed && { opacity: 0.82 }, + ]} + > + + Open Theme settings + + + + ); +} diff --git a/frontend/app/settings/theme.tsx b/frontend/app/settings/theme.tsx new file mode 100644 index 0000000..35dd6b7 --- /dev/null +++ b/frontend/app/settings/theme.tsx @@ -0,0 +1,352 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { Pressable, StyleSheet, View } from "react-native"; +import Slider from "@react-native-community/slider"; +import { ThemedText } from "@/components/ThemedText"; +import { usePreferences } from "@/contexts/PreferencesContext"; +import { useAppColors } from "@/hooks/useAppColors"; +import { SettingsPageShell, settingsStyles } from "@/features/settings/shared"; +import { + accentPresetOptions, + hexToHsv, + hexToRgb, + hsvToHex, + rgbToHex, + visualizationModeOptions, +} from "@/features/settings/utils"; + +export default function SettingsThemeScreen() { + const colors = useAppColors(); + const { preferences, updatePreferences } = usePreferences(); + const [accentHue, setAccentHue] = useState(140); + const [accentSaturation, setAccentSaturation] = useState(0.78); + const [accentValue, setAccentValue] = useState(0.95); + const [accentRed, setAccentRed] = useState(0); + const [accentGreen, setAccentGreen] = useState(243); + const [accentBlue, setAccentBlue] = useState(41); + const accentUpdateRef = useRef | null>(null); + + const accentPreview = useMemo( + () => hsvToHex(accentHue, accentSaturation, accentValue), + [accentHue, accentSaturation, accentValue], + ); + + useEffect(() => { + const seed = preferences.accentColor || colors.tint; + const { h, s, v } = hexToHsv(seed); + setAccentHue(h); + setAccentSaturation(s); + setAccentValue(v); + }, [colors.tint, preferences.accentColor]); + + useEffect(() => { + const rgb = hexToRgb(accentPreview); + setAccentRed(rgb.r); + setAccentGreen(rgb.g); + setAccentBlue(rgb.b); + }, [accentPreview]); + + useEffect(() => { + return () => { + if (accentUpdateRef.current) { + clearTimeout(accentUpdateRef.current); + } + }; + }, []); + + const scheduleAccentUpdate = (nextColor: string, flush = false) => { + if (accentUpdateRef.current) { + clearTimeout(accentUpdateRef.current); + accentUpdateRef.current = null; + } + + if (flush) { + void updatePreferences({ accentColor: nextColor }); + return; + } + + accentUpdateRef.current = setTimeout(() => { + void updatePreferences({ accentColor: nextColor }); + accentUpdateRef.current = null; + }, 160); + }; + + const applyRgbAccent = (red: number, green: number, blue: number) => { + const next = rgbToHex(red, green, blue); + const { h, s, v } = hexToHsv(next); + setAccentHue(h); + setAccentSaturation(s); + setAccentValue(v); + scheduleAccentUpdate(next); + }; + + return ( + + + Accent color + + + + + {accentPreview} + + + + + {accentPresetOptions.map((preset) => { + const active = + accentPreview.toUpperCase() === preset.color.toUpperCase(); + return ( + { + const next = preset.color.toUpperCase(); + const { h, s, v } = hexToHsv(next); + setAccentHue(h); + setAccentSaturation(s); + setAccentValue(v); + scheduleAccentUpdate(next, true); + }} + style={({ pressed }) => [ + settingsStyles.chip, + { + borderColor: colors.border, + backgroundColor: active ? colors.tint : colors.surfaceAlt, + }, + pressed && { opacity: 0.82 }, + ]} + > + + {preset.label} + + + ); + })} + + + + + + Visualization mode + + {visualizationModeOptions.map((option) => { + const active = preferences.visualizationMode === option.value; + return ( + + void updatePreferences({ visualizationMode: option.value }) + } + style={({ pressed }) => [ + settingsStyles.chip, + { + borderColor: colors.border, + backgroundColor: active ? colors.tint : colors.surfaceAlt, + }, + pressed && { opacity: 0.82 }, + ]} + > + + {option.label} + + + ); + })} + + + + + ); +} + +function Label({ text }: { text: string }) { + const colors = useAppColors(); + return ( + + {text} + + ); +} + +const styles = StyleSheet.create({ + previewRow: { + flexDirection: "row", + alignItems: "center", + gap: 10, + }, + accentPreview: { + width: 36, + height: 36, + borderRadius: 18, + borderWidth: 2, + }, +}); diff --git a/frontend/app/stream/[projectId].tsx b/frontend/app/stream/[projectId].tsx new file mode 100644 index 0000000..feac932 --- /dev/null +++ b/frontend/app/stream/[projectId].tsx @@ -0,0 +1,803 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + Animated, + Pressable, + RefreshControl, + StyleSheet, + View, +} from "react-native"; +import { Feather } from "@expo/vector-icons"; +import { + SafeAreaView, + useSafeAreaInsets, +} from "react-native-safe-area-context"; +import { useFocusEffect } from "@react-navigation/native"; +import { useLocalSearchParams, useRouter } from "expo-router"; +import * as Linking from "expo-linking"; +import { ApiProject } from "@/constants/Types"; +import { + clearApiCache, + getPostsByProjectId, + isProjectLiked, + likeProject, + getProjectBuilders, + getProjectById, + getUserById, + removeProjectBuilder, + unlikeProject, +} from "@/services/api"; +import { mapPostToUi } from "@/services/mappers"; +import { Post } from "@/components/Post"; +import { TagChip } from "@/components/TagChip"; +import { ThemedText } from "@/components/ThemedText"; +import { MarkdownText } from "@/components/MarkdownText"; +import { FloatingScrollTopButton } from "@/components/FloatingScrollTopButton"; +import { MediaGallery } from "@/components/MediaGallery"; +import { TopBlur } from "@/components/TopBlur"; +import { + UnifiedLoadingInline, + UnifiedLoadingList, +} from "@/components/UnifiedLoading"; +import { useBottomTabOverflow } from "@/components/ui/TabBarBackground"; +import { useAutoRefresh } from "@/hooks/useAutoRefresh"; +import { useAppColors } from "@/hooks/useAppColors"; +import { useMotionConfig } from "@/hooks/useMotionConfig"; +import { useTopBlurScroll } from "@/hooks/useTopBlurScroll"; +import { useAuth } from "@/contexts/AuthContext"; +import { useSavedStreams } from "@/contexts/SavedStreamsContext"; +import { emitProjectStats } from "@/services/projectEvents"; + +const ensureUrlScheme = (url: string) => + /^[a-z][a-z0-9+.-]*:/i.test(url) ? url : `https://${url}`; + +const toOneLine = (value: string) => value.replace(/\s+/g, " ").trim(); + +const StreamTitleMarkdown = React.memo(function StreamTitleMarkdown({ + title, +}: { + title: string; +}) { + return {toOneLine(title)}; +}); + +const StreamBodyMarkdown = React.memo(function StreamBodyMarkdown({ + description, + aboutMd, +}: { + description: string; + aboutMd?: string; +}) { + return ( + <> + {description} + {aboutMd ? {aboutMd} : null} + + ); +}); + +export default function StreamDetailScreen() { + const colors = useAppColors(); + const insets = useSafeAreaInsets(); + const router = useRouter(); + const { user } = useAuth(); + const { savedProjectIds, toggleSave } = useSavedStreams(); + const { projectId } = useLocalSearchParams<{ + projectId?: string | string[]; + }>(); + const [project, setProject] = useState(null); + const [posts, setPosts] = useState([] as ReturnType[]); + const [builders, setBuilders] = useState([]); + const [creatorName, setCreatorName] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isPostsLoading, setIsPostsLoading] = useState(false); + const [hasError, setHasError] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + const [isSaved, setIsSaved] = useState(false); + const [saveCount, setSaveCount] = useState(0); + const [isSaving, setIsSaving] = useState(false); + const [isLiked, setIsLiked] = useState(false); + const [likeCount, setLikeCount] = useState(0); + const [isLiking, setIsLiking] = useState(false); + const [isLeaving, setIsLeaving] = useState(false); + const bottom = useBottomTabOverflow(); + const motion = useMotionConfig(); + const reveal = useRef(new Animated.Value(0.08)).current; + const scrollRef = useRef(null); + const { scrollY, onScroll } = useTopBlurScroll(); + const hasLoadedOnceRef = useRef(false); + const loadSequenceRef = useRef(0); + const revealPostsTimerRef = useRef | null>( + null, + ); + const appendPostsTimerRef = useRef | null>( + null, + ); + const [visiblePostCount, setVisiblePostCount] = useState(0); + + const normalizedProjectId = useMemo( + () => (Array.isArray(projectId) ? projectId[0] : projectId), + [projectId], + ); + const projectIdNumber = useMemo(() => { + const parsed = Number.parseInt(String(normalizedProjectId ?? ""), 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return parsed; + }, [normalizedProjectId]); + const isCreator = useMemo( + () => (project && user?.id ? project.owner === user.id : false), + [project, user?.id], + ); + const isBuilder = useMemo( + () => + !isCreator && user?.username ? builders.includes(user.username) : false, + [builders, isCreator, user?.username], + ); + + const visiblePosts = useMemo( + () => posts.slice(0, visiblePostCount), + [posts, visiblePostCount], + ); + + useEffect(() => { + if (motion.prefersReducedMotion) { + reveal.setValue(1); + return; + } + + Animated.timing(reveal, { + toValue: 1, + duration: motion.duration(420), + useNativeDriver: true, + }).start(); + }, [motion, reveal]); + + const loadStream = useCallback( + async (showLoader = true) => { + if (projectIdNumber == null) { + setProject(null); + setPosts([]); + setVisiblePostCount(0); + setBuilders([]); + setCreatorName(null); + setIsPostsLoading(false); + setHasError(true); + if (showLoader) { + setIsLoading(false); + } + return; + } + const loadSequence = ++loadSequenceRef.current; + if (appendPostsTimerRef.current) { + clearTimeout(appendPostsTimerRef.current); + appendPostsTimerRef.current = null; + } + try { + if (showLoader) { + setIsLoading(true); + } + const projectPromise = getProjectById(projectIdNumber); + const postsPromise = getPostsByProjectId(projectIdNumber); + const projectData = await projectPromise; + if (loadSequence !== loadSequenceRef.current) { + return; + } + + const [builderList, likeStatus, ownerUser] = await Promise.all([ + getProjectBuilders(projectIdNumber).catch(() => []), + user?.username + ? isProjectLiked(user.username, projectIdNumber).catch(() => ({ + status: false, + })) + : Promise.resolve({ status: false }), + getUserById(projectData.owner).catch(() => null), + ]); + if (loadSequence !== loadSequenceRef.current) { + return; + } + + setProject(projectData); + setBuilders(Array.isArray(builderList) ? builderList : []); + setCreatorName(ownerUser?.username ?? `user-${projectData.owner}`); + setIsLiked(likeStatus.status); + setHasError(false); + hasLoadedOnceRef.current = true; + if (showLoader) { + setIsPostsLoading(true); + setPosts([]); + setVisiblePostCount(0); + } + if (showLoader) { + setIsLoading(false); + } + + const projectPosts = await postsPromise; + if (loadSequence !== loadSequenceRef.current) { + return; + } + const safePosts = Array.isArray(projectPosts) ? projectPosts : []; + const userCache = new Map< + number, + Awaited> | null + >(); + + const mapBatch = async (batch: typeof safePosts) => + Promise.all( + batch.map(async (post) => { + let postUser = userCache.get(post.user); + if (typeof postUser === "undefined") { + postUser = await getUserById(post.user).catch(() => null); + userCache.set(post.user, postUser); + } + return mapPostToUi(post, postUser, projectData); + }), + ); + + const firstBatchSize = 10; + const appendBatchSize = 14; + const firstMapped = await mapBatch(safePosts.slice(0, firstBatchSize)); + if (loadSequence !== loadSequenceRef.current) { + return; + } + + setPosts(firstMapped); + + let nextStart = firstBatchSize; + const appendNextBatch = async () => { + if (loadSequence !== loadSequenceRef.current) { + return; + } + if (nextStart >= safePosts.length) { + setIsPostsLoading(false); + return; + } + + const nextEnd = Math.min( + safePosts.length, + nextStart + appendBatchSize, + ); + const mapped = await mapBatch(safePosts.slice(nextStart, nextEnd)); + if (loadSequence !== loadSequenceRef.current) { + return; + } + setPosts((prev) => prev.concat(mapped)); + nextStart = nextEnd; + + if (nextStart < safePosts.length) { + appendPostsTimerRef.current = setTimeout(() => { + void appendNextBatch(); + }, 0); + } else { + setIsPostsLoading(false); + } + }; + + if (safePosts.length > firstBatchSize) { + appendPostsTimerRef.current = setTimeout(() => { + void appendNextBatch(); + }, 0); + } else { + setIsPostsLoading(false); + } + } catch { + if (loadSequence !== loadSequenceRef.current) { + return; + } + if (showLoader) { + setProject(null); + setPosts([]); + setVisiblePostCount(0); + setBuilders([]); + setCreatorName(null); + } + setIsPostsLoading(false); + setHasError(true); + } finally { + if (showLoader && loadSequence === loadSequenceRef.current) { + setIsLoading(false); + } + } + }, + [projectIdNumber, user?.username], + ); + + useEffect(() => { + if (revealPostsTimerRef.current) { + clearTimeout(revealPostsTimerRef.current); + revealPostsTimerRef.current = null; + } + + const initialCount = Math.min(posts.length, 8); + setVisiblePostCount(initialCount); + + if (initialCount >= posts.length) { + return; + } + + const step = 8; + const revealNext = () => { + setVisiblePostCount((prev) => { + const next = Math.min(posts.length, prev + step); + if (next < posts.length) { + revealPostsTimerRef.current = setTimeout(revealNext, 24); + } + return next; + }); + }; + + revealPostsTimerRef.current = setTimeout(revealNext, 24); + + return () => { + if (revealPostsTimerRef.current) { + clearTimeout(revealPostsTimerRef.current); + revealPostsTimerRef.current = null; + } + }; + }, [posts.length]); + + useEffect(() => { + return () => { + loadSequenceRef.current += 1; + if (appendPostsTimerRef.current) { + clearTimeout(appendPostsTimerRef.current); + appendPostsTimerRef.current = null; + } + if (revealPostsTimerRef.current) { + clearTimeout(revealPostsTimerRef.current); + revealPostsTimerRef.current = null; + } + }; + }, []); + + useEffect(() => { + loadStream(); + }, [loadStream]); + + useFocusEffect( + useCallback(() => { + if (!hasLoadedOnceRef.current) { + return; + } + clearApiCache(); + loadStream(false); + }, [loadStream]), + ); + + const handleRefresh = useCallback(async () => { + setIsRefreshing(true); + clearApiCache(); + await loadStream(false); + setIsRefreshing(false); + }, [loadStream]); + + useAutoRefresh(() => loadStream(false), { focusRefresh: false }); + + useEffect(() => { + if (projectIdNumber == null) { + return; + } + setIsSaved(savedProjectIds.includes(projectIdNumber)); + }, [projectIdNumber, savedProjectIds]); + + useEffect(() => { + setSaveCount(project?.saves ?? 0); + }, [project?.saves]); + + useEffect(() => { + setLikeCount(project?.likes ?? 0); + }, [project?.likes]); + + const handleToggleSave = async () => { + if (!user?.username || projectIdNumber == null || isSaving) { + return; + } + setIsSaving(true); + try { + const nextSaved = !isSaved; + const nextCount = Math.max(0, saveCount + (nextSaved ? 1 : -1)); + await toggleSave(projectIdNumber); + setIsSaved(nextSaved); + setSaveCount(nextCount); + emitProjectStats(projectIdNumber, { saves: nextCount }); + } finally { + setIsSaving(false); + } + }; + + const handleToggleLike = async () => { + if (!user?.username || projectIdNumber == null || isLiking) { + return; + } + setIsLiking(true); + const nextLiked = !isLiked; + const nextCount = Math.max(0, likeCount + (nextLiked ? 1 : -1)); + setIsLiked(nextLiked); + setLikeCount(nextCount); + emitProjectStats(projectIdNumber, { likes: nextCount, isLiked: nextLiked }); + try { + if (nextLiked) { + await likeProject(user.username, projectIdNumber); + } else { + await unlikeProject(user.username, projectIdNumber); + } + } catch { + setIsLiked(!nextLiked); + setLikeCount(likeCount); + emitProjectStats(projectIdNumber, { likes: likeCount, isLiked }); + } finally { + setIsLiking(false); + } + }; + + const handleLeaveBuilder = async () => { + if (projectIdNumber == null || !user?.username || !isBuilder || isLeaving) { + return; + } + setIsLeaving(true); + try { + await removeProjectBuilder(projectIdNumber, user.username); + setBuilders((prev) => + prev.filter((builder) => builder !== user.username), + ); + } finally { + setIsLeaving(false); + } + }; + + const stageLabel = project + ? project.status === 2 + ? "launch" + : project.status === 1 + ? "beta" + : "alpha" + : "alpha"; + + const createdLabel = useMemo(() => { + if (!project?.creation_date) { + return ""; + } + const createdAt = new Date(project.creation_date); + if (Number.isNaN(createdAt.getTime())) { + return ""; + } + return createdAt.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); + }, [project?.creation_date]); + + return ( + + + + } + scrollIndicatorInsets={{ bottom: bottom + insets.bottom }} + contentContainerStyle={[ + styles.container, + { + paddingTop: 8, + paddingBottom: bottom + insets.bottom + 32, + }, + ]} + > + + {isLoading ? ( + + ) : project ? ( + + + + {createdLabel || creatorName ? ( + + {createdLabel ? ( + + Created {createdLabel} + + ) : null} + {creatorName ? ( + + router.push({ + pathname: "/user/[username]", + params: { username: creatorName }, + }) + } + > + + Creator {creatorName} + + + ) : null} + + ) : null} + + + {isCreator ? ( + + ) : null} + {isBuilder ? ( + + ) : null} + + + {isBuilder ? ( + [ + styles.saveButton, + { + borderColor: colors.border, + backgroundColor: colors.surfaceAlt, + }, + pressed && styles.saveButtonPressed, + ]} + disabled={isLeaving} + > + + Leave + + + ) : null} + {isCreator || isBuilder ? ( + + router.push({ + pathname: "/manage-stream/[projectId]", + params: { projectId: String(projectIdNumber) }, + }) + } + style={({ pressed }) => [ + styles.saveButton, + { + borderColor: colors.border, + backgroundColor: colors.surfaceAlt, + }, + pressed && styles.saveButtonPressed, + ]} + > + + + + Edit + + + + ) : null} + [ + styles.saveButton, + { + borderColor: colors.border, + backgroundColor: colors.surfaceAlt, + }, + pressed && styles.saveButtonPressed, + ]} + disabled={isLiking} + > + + + + {likeCount} + + + + [ + styles.saveButton, + { + borderColor: colors.border, + backgroundColor: colors.surfaceAlt, + }, + pressed && styles.saveButtonPressed, + ]} + disabled={isSaving} + > + + + + {saveCount} + + + + + + + + + + + {project.links?.length ? ( + + {project.links.map((link) => ( + Linking.openURL(ensureUrlScheme(link))} + > + + {link} + + + ))} + + ) : null} + + {builders.length ? ( + + + Builders: {builders.join(", ")} + + + ) : null} + + ) : ( + + + {hasError ? "Unable to load stream." : "Stream not found."} + + + )} + + + {visiblePosts.length ? ( + visiblePosts.map((post) => ) + ) : isPostsLoading ? ( + + ) : ( + + + {hasError ? "Unable to load bytes." : "No bytes yet."} + + + )} + {isPostsLoading && visiblePosts.length > 0 ? ( + + ) : null} + + + + scrollRef.current?.scrollTo({ y: 0, animated: true })} + bottomOffset={insets.bottom + 20} + /> + + ); +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + }, + safeArea: { + flex: 1, + }, + container: { + paddingVertical: 16, + paddingHorizontal: 16, + gap: 16, + paddingTop: 0, + }, + emptyState: { + alignItems: "center", + paddingVertical: 24, + }, + streamCard: { + gap: 12, + }, + headerBlock: { + gap: 8, + }, + metaRow: { + flexDirection: "row", + flexWrap: "wrap", + gap: 10, + }, + chipRow: { + flexDirection: "row", + flexWrap: "wrap", + gap: 8, + }, + actionRow: { + flexDirection: "row", + flexWrap: "wrap", + gap: 10, + alignItems: "center", + }, + saveButton: { + borderRadius: 10, + borderWidth: 1, + paddingHorizontal: 10, + paddingVertical: 6, + borderColor: "transparent", + }, + saveButtonContent: { + flexDirection: "row", + alignItems: "center", + gap: 6, + }, + saveButtonPressed: { + opacity: 0.8, + transform: [{ scale: 0.98 }], + }, + linkList: { + gap: 6, + }, + builderRow: { + paddingTop: 4, + }, +}); diff --git a/frontend/app/streams.tsx b/frontend/app/streams.tsx new file mode 100644 index 0000000..290855c --- /dev/null +++ b/frontend/app/streams.tsx @@ -0,0 +1,463 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { + Animated, + FlatList, + Pressable, + RefreshControl, + StyleSheet, + View, +} from "react-native"; +import { + SafeAreaView, + useSafeAreaInsets, +} from "react-native-safe-area-context"; +import { useFocusEffect } from "@react-navigation/native"; +import { + clearApiCache, + FeedSort, + getFollowingProjectsFeed, + getProjectBuilders, + getProjectsByBuilderId, + getProjectsFeed, + getSavedProjectsFeed, +} from "@/services/api"; +import { mapProjectToUi } from "@/services/mappers"; +import { ProjectCard } from "@/components/ProjectCard"; +import { FloatingScrollTopButton } from "@/components/FloatingScrollTopButton"; +import { ThemedText } from "@/components/ThemedText"; +import { TopBlur } from "@/components/TopBlur"; +import { + UnifiedLoadingInline, + UnifiedLoadingList, +} from "@/components/UnifiedLoading"; +import { useAutoRefresh } from "@/hooks/useAutoRefresh"; +import { useAppColors } from "@/hooks/useAppColors"; +import { useMotionConfig } from "@/hooks/useMotionConfig"; +import { useTopBlurScroll } from "@/hooks/useTopBlurScroll"; +import { useRequestGuard } from "@/hooks/useRequestGuard"; +import { useAuth } from "@/contexts/AuthContext"; +import { useSavedStreams } from "@/contexts/SavedStreamsContext"; +import { + applyProjectEvent, + subscribeToProjectEvents, +} from "@/services/projectEvents"; + +export default function StreamsScreen() { + const colors = useAppColors(); + const insets = useSafeAreaInsets(); + const { user } = useAuth(); + const [projects, setProjects] = useState( + [] as ReturnType[], + ); + const { savedProjectIds } = useSavedStreams(); + const [builderProjectIds, setBuilderProjectIds] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [hasError, setHasError] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + const [activeFilter, setActiveFilter] = useState< + "all" | "following" | "saved" + >("all"); + const [activeSort, setActiveSort] = useState< + "recent" | "new" | "popular" | "hot" + >("recent"); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(true); + const [pageIndex, setPageIndex] = useState(0); + const motion = useMotionConfig(); + const requestGuard = useRequestGuard(); + const reveal = useRef(new Animated.Value(0.08)).current; + const listRef = useRef>>(null); + const { scrollY, onScroll } = useTopBlurScroll(); + const pageSize = 24; + + useEffect(() => { + if (motion.prefersReducedMotion) { + reveal.setValue(1); + return; + } + + Animated.timing(reveal, { + toValue: 1, + duration: motion.duration(420), + useNativeDriver: true, + }).start(); + }, [motion, reveal]); + + const loadStreams = useCallback( + async ({ + showLoader = true, + nextPage = 0, + append = false, + }: { + showLoader?: boolean; + nextPage?: number; + append?: boolean; + } = {}) => { + const requestId = requestGuard.beginRequest(); + try { + if (showLoader && requestGuard.isMounted()) { + setIsLoading(true); + } + const start = nextPage * pageSize; + const projectFeedPromise = + activeFilter === "following" && user?.username + ? getFollowingProjectsFeed( + user.username, + start, + pageSize, + activeSort, + ) + : activeFilter === "saved" && user?.username + ? getSavedProjectsFeed(user.username, start, pageSize, activeSort) + : getProjectsFeed(activeSort as FeedSort, start, pageSize); + + let projectFeedRaw: Awaited> = []; + try { + projectFeedRaw = await projectFeedPromise; + } catch { + if (activeFilter !== "all") { + projectFeedRaw = await getProjectsFeed( + activeSort as FeedSort, + start, + pageSize, + ); + if (requestGuard.isMounted()) { + setActiveFilter("all"); + } + } else { + throw new Error("Failed to load project feed"); + } + } + + const builderProjectsRaw = + nextPage === 0 && user?.id + ? await getProjectsByBuilderId(user.id).catch(() => []) + : []; + + const projectFeed = Array.isArray(projectFeedRaw) ? projectFeedRaw : []; + const builderProjects = Array.isArray(builderProjectsRaw) + ? builderProjectsRaw + : []; + const builderIds = builderProjects.map((project) => project.id); + const builderCounts = await Promise.all( + projectFeed.map((project) => + getProjectBuilders(project.id).catch(() => []), + ), + ); + const uiProjects = projectFeed.map((project, index) => + mapProjectToUi(project, builderCounts[index]?.length ?? 0), + ); + if (!requestGuard.isActive(requestId)) { + return; + } + + setProjects((prev) => (append ? prev.concat(uiProjects) : uiProjects)); + if (nextPage === 0) { + setBuilderProjectIds(builderIds); + } + setPageIndex(nextPage); + setHasMore(projectFeed.length === pageSize); + setHasError(false); + } catch { + if (!requestGuard.isActive(requestId)) { + return; + } + if (!append) { + setProjects([]); + } + setHasError(true); + } finally { + if (showLoader && requestGuard.isMounted()) { + setIsLoading(false); + } + } + }, + [activeFilter, activeSort, requestGuard, user?.id, user?.username], + ); + + useEffect(() => { + setHasMore(true); + setPageIndex(0); + loadStreams({ nextPage: 0 }); + }, [loadStreams]); + + useEffect(() => { + return subscribeToProjectEvents((event) => { + setProjects((prev) => applyProjectEvent(prev, event)); + }); + }, []); + + useFocusEffect( + useCallback(() => { + clearApiCache(); + loadStreams({ showLoader: false, nextPage: 0 }); + }, [loadStreams]), + ); + + const handleRefresh = useCallback(async () => { + setIsRefreshing(true); + clearApiCache(); + setHasMore(true); + setPageIndex(0); + await loadStreams({ showLoader: false, nextPage: 0 }); + setIsRefreshing(false); + }, [loadStreams]); + + useAutoRefresh(() => loadStreams({ showLoader: false, nextPage: 0 }), { + focusRefresh: false, + }); + + const handleLoadMore = useCallback(() => { + if (isLoadingMore || isLoading) { + return; + } + if (!hasMore) { + return; + } + setIsLoadingMore(true); + loadStreams({ + showLoader: false, + nextPage: pageIndex + 1, + append: true, + }).finally(() => { + if (requestGuard.isMounted()) { + setIsLoadingMore(false); + } + }); + }, [hasMore, isLoading, isLoadingMore, loadStreams, pageIndex, requestGuard]); + + return ( + + + + String(item.id)} + renderItem={({ item }) => ( + undefined} + /> + )} + initialNumToRender={6} + maxToRenderPerBatch={8} + updateCellsBatchingPeriod={40} + windowSize={8} + removeClippedSubviews + onEndReached={handleLoadMore} + onEndReachedThreshold={0.5} + onScroll={onScroll} + scrollEventThrottle={16} + refreshControl={ + + } + ListHeaderComponent={ + + + + + + Active streams + + + Projects shipping right now. + + + + + {["all", "following", "saved"].map((key) => { + const isActive = key === activeFilter; + return ( + + setActiveFilter(key as "all" | "following" | "saved") + } + style={[ + styles.filterChip, + { + backgroundColor: isActive + ? colors.tint + : colors.surfaceAlt, + borderColor: colors.border, + }, + ]} + > + + {key === "all" + ? "All" + : key === "following" + ? "Following" + : "Saved"} + + + ); + })} + + + {[ + { key: "recent", label: "Recent" }, + { key: "new", label: "Newest" }, + { key: "popular", label: "Popular" }, + { key: "hot", label: "HOT" }, + ].map(({ key, label }) => { + const isActive = key === activeSort; + return ( + + setActiveSort( + key as "recent" | "new" | "popular" | "hot", + ) + } + style={[ + styles.sortChip, + { + backgroundColor: isActive + ? colors.tint + : colors.surfaceAlt, + borderColor: colors.border, + }, + ]} + > + + {label} + + + ); + })} + + + {isLoading ? ( + + ) : null} + + } + ListEmptyComponent={ + !isLoading ? ( + + + {hasError + ? "Streams unavailable. Check the API and try again." + : activeFilter === "saved" + ? "No saved streams yet." + : activeFilter === "following" + ? "No following streams yet." + : "No streams yet."} + + + ) : null + } + ListFooterComponent={ + isLoadingMore ? ( + + ) : null + } + contentContainerStyle={[ + styles.container, + { paddingTop: 8, paddingBottom: 96 + insets.bottom }, + ]} + /> + + + + listRef.current?.scrollToOffset({ offset: 0, animated: true }) + } + bottomOffset={insets.bottom + 20} + /> + + ); +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + }, + safeArea: { + flex: 1, + }, + background: { + ...StyleSheet.absoluteFillObject, + }, + container: { + paddingVertical: 16, + paddingHorizontal: 16, + gap: 16, + paddingTop: 0, + }, + title: { + fontSize: 26, + lineHeight: 30, + }, + headerRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "flex-start", + gap: 12, + }, + filterRow: { + flexDirection: "row", + gap: 8, + paddingTop: 6, + }, + sortRow: { + flexDirection: "row", + gap: 8, + paddingTop: 8, + flexWrap: "wrap", + }, + filterChip: { + paddingVertical: 6, + paddingHorizontal: 10, + borderRadius: 10, + borderWidth: 1, + }, + sortChip: { + paddingVertical: 6, + paddingHorizontal: 10, + borderRadius: 10, + borderWidth: 1, + }, + projectGrid: { + gap: 12, + }, + emptyState: { + alignItems: "center", + paddingVertical: 24, + }, +}); diff --git a/frontend/app/terminal.tsx b/frontend/app/terminal.tsx new file mode 100644 index 0000000..0f99313 --- /dev/null +++ b/frontend/app/terminal.tsx @@ -0,0 +1,1320 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + Keyboard, + KeyboardAvoidingView, + Platform, + Pressable, + ScrollView, + StyleSheet, + TextInput, + View, +} from "react-native"; +import { + SafeAreaView, + useSafeAreaInsets, +} from "react-native-safe-area-context"; +import { Feather } from "@expo/vector-icons"; +import { useLocalSearchParams, useRouter } from "expo-router"; +import { ThemedText } from "@/components/ThemedText"; +import { useAppColors } from "@/hooks/useAppColors"; +import { useAuth } from "@/contexts/AuthContext"; +import { useNotifications } from "@/contexts/NotificationsContext"; +import { ApiDirectMessage } from "@/constants/Types"; +import { + API_BASE_URL, + ApiDirectMessageThread, + createDirectMessage, + getAllUsers, + getDirectChatPeers, + getDirectMessageThreads, + getDirectMessages, + getUsersFollowingUsernames, +} from "@/services/api"; + +type TerminalLine = { + id: number; + type: "cmd" | "out" | "err"; + text: string; + chatRole?: "me" | "them"; +}; + +type ChatEntry = { + id: number; + author: "me" | "them"; + text: string; + timestamp: string; +}; + +type DirectMessageStreamEvent = { + type: "direct_message"; + direct_message: ApiDirectMessage; +}; + +type TerminalSuggestion = { + id: string; + label: string; + value: string; +}; + +const normalizeUsername = (value: string) => + value.replace(/^@+/, "").trim().toLowerCase(); + +const formatTime = (dateValue: string) => { + const date = new Date(dateValue); + if (Number.isNaN(date.getTime())) { + return "now"; + } + return date.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + }); +}; + +const mapMessageToChatEntry = ( + me: string, + message: ApiDirectMessage, +): ChatEntry => { + const myName = normalizeUsername(me); + const sender = normalizeUsername(message.sender_name); + return { + id: message.id, + author: sender === myName ? "me" : "them", + text: message.content, + timestamp: formatTime(message.created_at), + }; +}; + +const getPeerForMessage = (me: string, message: ApiDirectMessage) => { + const myName = normalizeUsername(me); + const sender = normalizeUsername(message.sender_name); + const recipient = normalizeUsername(message.recipient_name); + return sender === myName ? recipient : sender; +}; + +const getWebSocketBaseUrl = (baseUrl?: string) => { + if (!baseUrl) { + return ""; + } + if (baseUrl.startsWith("https://")) { + return `wss://${baseUrl.slice("https://".length)}`; + } + if (baseUrl.startsWith("http://")) { + return `ws://${baseUrl.slice("http://".length)}`; + } + return baseUrl; +}; + +const truncatePreview = (text: string, max = 46) => { + const value = text.trim(); + if (value.length <= max) { + return value; + } + return `${value.slice(0, max - 1)}…`; +}; + +const formatInboxTime = (value: string) => { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return "now"; + } + + const diffMs = Date.now() - date.getTime(); + const minute = 60 * 1000; + const hour = 60 * minute; + const day = 24 * hour; + + if (diffMs < minute) { + return "now"; + } + if (diffMs < hour) { + return `${Math.floor(diffMs / minute)}m`; + } + if (diffMs < day) { + return `${Math.floor(diffMs / hour)}h`; + } + return `${Math.floor(diffMs / day)}d`; +}; + +const parseApiErrorMessage = (error: unknown, fallback: string) => { + if (!(error instanceof Error)) { + return fallback; + } + + const rawMessage = error.message?.trim(); + if (!rawMessage) { + return fallback; + } + + try { + const parsed = JSON.parse(rawMessage) as { + error?: string; + message?: string; + }; + const value = parsed.error ?? parsed.message; + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + } catch { + // Keep raw error text when message is not JSON. + } + + return rawMessage; +}; + +const isMissingDirectMessageUserError = (error: unknown) => { + const message = parseApiErrorMessage(error, "").toLowerCase(); + return ( + message.includes("failed to fetch direct messages") && + message.includes("not found") + ); +}; + +export default function TerminalScreen() { + const colors = useAppColors(); + const router = useRouter(); + const params = useLocalSearchParams<{ chat?: string | string[] }>(); + const { user, token } = useAuth(); + const { showInAppBanner } = useNotifications(); + const insets = useSafeAreaInsets(); + const outputRef = useRef(null); + const outputScrollRafRef = useRef(null); + const autoOpenedChatRef = useRef(null); + const activeChatRef = useRef(null); + const wsRef = useRef(null); + const reconnectTimeoutRef = useRef | null>( + null, + ); + const seenMessageIdsRef = useRef>(new Set()); + const spamWindowByPeerRef = useRef>({}); + const spamCooldownByPeerRef = useRef>({}); + const [input, setInput] = useState(""); + const [activeChat, setActiveChat] = useState(null); + const [chatThreads, setChatThreads] = useState>( + {}, + ); + const [allUsers, setAllUsers] = useState([]); + const [friendUsers, setFriendUsers] = useState([]); + const [chatPeers, setChatPeers] = useState([]); + const [lines, setLines] = useState([ + { id: 1, type: "out", text: "DevBits Terminal v1" }, + { id: 2, type: "out", text: "Type 'help' to list commands." }, + { id: 3, type: "out", text: "Open your inbox: inbox" }, + { id: 4, type: "out", text: "Start a chat: chat username" }, + { id: 5, type: "out", text: "" }, + ]); + + const movePeerToTop = useCallback((peerUsername: string) => { + const normalizedPeer = normalizeUsername(peerUsername); + if (!normalizedPeer) { + return; + } + setChatPeers((prev) => [ + normalizedPeer, + ...prev.filter((entry) => entry !== normalizedPeer), + ]); + }, []); + + const commandHelp = useMemo( + () => [ + "help show available commands", + "clear clear terminal output", + "echo print text", + "inbox show direct-message threads", + "friends list your follows and active chats", + "chat open terminal chat session", + "exit leave active chat session", + "msg @user send message to a user", + "open home|explore navigate app sections", + "status show current session status", + ], + [], + ); + + const commandNames = useMemo( + () => [ + "help", + "clear", + "echo", + "inbox", + "friends", + "chat", + "exit", + "msg", + "open", + "status", + ], + [], + ); + + useEffect(() => { + let active = true; + if (!user?.username) { + setAllUsers([]); + setFriendUsers([]); + setChatPeers([]); + return; + } + + void Promise.all([ + getAllUsers(0, 200).catch(() => []), + getUsersFollowingUsernames(user.username).catch(() => []), + getDirectMessageThreads(user.username, 0, 100).catch(() => []), + getDirectChatPeers(user.username).catch(() => []), + ]).then(([users, following, threads, peers]) => { + if (!active) { + return; + } + const usernames = Array.isArray(users) + ? users + .map((entry) => entry.username) + .filter((name): name is string => Boolean(name)) + .map((name) => normalizeUsername(name)) + : []; + setAllUsers(Array.from(new Set(usernames)).sort()); + const follows = Array.isArray(following) + ? following.map((name) => normalizeUsername(name)).filter(Boolean) + : []; + setFriendUsers(Array.from(new Set(follows)).sort()); + const threadPeers = Array.isArray(threads) + ? threads + .map((thread) => normalizeUsername(thread.peer_username)) + .filter(Boolean) + : []; + const peerNames = Array.isArray(peers) + ? peers.map((name) => normalizeUsername(name)).filter(Boolean) + : []; + setChatPeers(Array.from(new Set([...threadPeers, ...peerNames]))); + }); + + return () => { + active = false; + }; + }, [user?.username]); + + const suggestions = useMemo(() => { + const trimmed = input.trim(); + if (!trimmed) { + return []; + } + + if (activeChat) { + const chatCommands = ["/help", "/exit"]; + return chatCommands + .filter((item) => item.startsWith(trimmed.toLowerCase())) + .map((item) => ({ + id: `chat-${item}`, + label: item, + value: item, + })) + .slice(0, 5); + } + + const [commandRaw, ...args] = trimmed.split(/\s+/); + const command = commandRaw.toLowerCase(); + + if (!trimmed.includes(" ")) { + return commandNames + .filter((item) => item.startsWith(command)) + .map((item) => ({ + id: `cmd-${item}`, + label: item, + value: item, + })) + .slice(0, 5); + } + + if (command === "open") { + const target = (args[0] ?? "").toLowerCase(); + return ["home", "explore"] + .filter((item) => item.startsWith(target)) + .map((item) => ({ + id: `open-${item}`, + label: `open ${item}`, + value: `open ${item}`, + })); + } + + if (command === "chat") { + const typed = normalizeUsername(args[0] ?? ""); + const source = Array.from( + new Set([ + ...friendUsers, + ...chatPeers, + ...Object.keys(chatThreads), + ...allUsers, + ]), + ); + return source + .filter((name) => !typed || name.startsWith(typed)) + .slice(0, 5) + .map((name) => ({ + id: `chat-user-${name}`, + label: `chat ${name}`, + value: `chat ${name}`, + })); + } + + if (command === "msg") { + const targetRaw = args[0] ?? ""; + const typed = normalizeUsername(targetRaw); + const source = Array.from(new Set([...friendUsers, ...allUsers])); + const base = source + .filter((name) => !typed || name.startsWith(typed)) + .slice(0, 5) + .map((name) => ({ + id: `msg-user-${name}`, + label: `msg @${name}`, + value: `msg @${name} `, + })); + + if (args.length > 1) { + return []; + } + return base; + } + + return []; + }, [ + activeChat, + allUsers, + chatPeers, + chatThreads, + commandNames, + friendUsers, + input, + ]); + + const appendLines = (entries: Omit[]) => { + setLines((prev) => { + const start = prev.length ? prev[prev.length - 1].id + 1 : 1; + const mapped = entries.map((entry, index) => ({ + id: start + index, + ...entry, + })); + return [...prev, ...mapped]; + }); + }; + + const scheduleOutputScroll = useCallback((animated: boolean) => { + if (outputScrollRafRef.current !== null) { + cancelAnimationFrame(outputScrollRafRef.current); + outputScrollRafRef.current = null; + } + outputScrollRafRef.current = requestAnimationFrame(() => { + outputRef.current?.scrollToEnd({ animated }); + outputScrollRafRef.current = null; + }); + }, []); + + const notifyIncomingMessage = useCallback( + (peer: string, text: string) => { + const now = Date.now(); + const windowMs = 5000; + const spamThreshold = 10; + const spamCooldownMs = 15000; + const spamBannerHoldMs = 4000; + + const existing = spamWindowByPeerRef.current[peer] ?? []; + const recent = existing.filter( + (timestamp) => now - timestamp <= windowMs, + ); + recent.push(now); + spamWindowByPeerRef.current[peer] = recent; + + const lastSpam = spamCooldownByPeerRef.current[peer] ?? 0; + if (recent.length >= spamThreshold && now - lastSpam > spamCooldownMs) { + spamCooldownByPeerRef.current[peer] = now; + showInAppBanner({ + title: "Spam detected", + body: `@${peer} is spamming you — Stack overflow: inbox hit 10 msgs in 5s.`, + payload: { type: "direct_message", actor_name: peer }, + incrementUnread: true, + }); + return; + } + + if (now - lastSpam < spamBannerHoldMs) { + return; + } + + showInAppBanner({ + title: "New message", + body: `@${peer}: ${text}`, + payload: { type: "direct_message", actor_name: peer }, + incrementUnread: true, + }); + }, + [showInAppBanner], + ); + + const appendChatEntries = (username: string, entries: ChatEntry[]) => { + const normalizedUser = normalizeUsername(username); + if (!normalizedUser || entries.length === 0) { + return [] as ChatEntry[]; + } + + const uniqueEntries = entries.filter((entry) => { + if (seenMessageIdsRef.current.has(entry.id)) { + return false; + } + seenMessageIdsRef.current.add(entry.id); + return true; + }); + if (!uniqueEntries.length) { + return [] as ChatEntry[]; + } + + setChatThreads((prev) => { + const existing = prev[normalizedUser] ?? []; + return { + ...prev, + [normalizedUser]: [...existing, ...uniqueEntries], + }; + }); + + return uniqueEntries; + }; + + const renderChatHistory = useCallback( + (username: string, entries?: ChatEntry[]) => { + const normalizedUser = normalizeUsername(username); + const thread = entries ?? chatThreads[normalizedUser] ?? []; + if (!thread.length) { + appendLines([ + { type: "out", text: `chat @${normalizedUser}` }, + { type: "out", text: "No previous messages. Say hello." }, + ]); + return; + } + + appendLines([ + { type: "out", text: `chat @${normalizedUser}` }, + ...thread.map((entry) => ({ + type: "out" as const, + chatRole: entry.author, + text: + entry.author === "me" + ? `[${entry.timestamp}] you: ${entry.text}` + : `[${entry.timestamp}] @${normalizedUser}: ${entry.text}`, + })), + ]); + }, + [chatThreads], + ); + + const loadChatHistory = useCallback( + async (username: string) => { + const normalizedUser = normalizeUsername(username); + if (!user?.username) { + appendLines([{ type: "err", text: "Sign in to open chat." }]); + return; + } + + try { + const messages = await getDirectMessages( + user.username, + normalizedUser, + 0, + 200, + ); + const mapped = messages.map((message) => + mapMessageToChatEntry(user.username ?? "", message), + ); + const historyIds = messages.map((message) => message.id); + for (const messageID of historyIds) { + seenMessageIdsRef.current.add(messageID); + } + setChatThreads((prev) => ({ + ...prev, + [normalizedUser]: mapped, + })); + renderChatHistory(normalizedUser, mapped); + } catch (error) { + if (isMissingDirectMessageUserError(error)) { + appendLines([ + { + type: "err", + text: `Chat unavailable: @${normalizedUser} does not exist.`, + }, + ]); + setActiveChat((current) => + current === normalizedUser ? null : current, + ); + return; + } + + const message = parseApiErrorMessage( + error, + `Failed to load chat with @${normalizedUser}`, + ); + appendLines([{ type: "err", text: message }]); + } + }, + [renderChatHistory, user?.username], + ); + + const openChatSession = useCallback( + async (username: string) => { + const target = normalizeUsername(username); + if (!target) { + return; + } + setActiveChat(target); + appendLines([ + { type: "out", text: `Chat mode: @${target} (type /exit to leave)` }, + ]); + await loadChatHistory(target); + }, + [loadChatHistory], + ); + + const renderInbox = useCallback(async () => { + if (!user?.username) { + appendLines([{ type: "err", text: "Sign in to view inbox." }]); + return; + } + + const threads = await getDirectMessageThreads(user.username, 0, 50).catch( + () => [] as ApiDirectMessageThread[], + ); + + if (!threads.length) { + appendLines([ + { type: "out", text: "Inbox" }, + { type: "out", text: "No direct messages yet." }, + ]); + return; + } + + const normalizedPeers = threads + .map((thread) => normalizeUsername(thread.peer_username)) + .filter(Boolean); + setChatPeers((prev) => Array.from(new Set([...normalizedPeers, ...prev]))); + + appendLines([ + { type: "out", text: "Inbox" }, + ...threads.map((thread) => { + const peer = normalizeUsername(thread.peer_username); + const preview = truncatePreview(thread.last_content || ""); + const when = formatInboxTime(thread.last_at); + return { + type: "out" as const, + text: `@${peer.padEnd(18, " ")} ${when.padStart(3, " ")} ${preview}`, + }; + }), + { type: "out", text: "Use: chat " }, + ]); + }, [user?.username]); + + const handleSendChatMessage = async (targetUser: string, message: string) => { + const normalizedUser = normalizeUsername(targetUser); + const trimmedMessage = message.trim(); + if (!normalizedUser || !trimmedMessage || !user?.username) { + return; + } + + const optimisticTimestamp = formatTime(new Date().toISOString()); + appendLines([ + { + type: "out", + chatRole: "me", + text: `[${optimisticTimestamp}] you: ${trimmedMessage}`, + }, + ]); + + try { + const response = await createDirectMessage( + user.username, + normalizedUser, + trimmedMessage, + ); + const created = response.direct_message; + const entry = mapMessageToChatEntry(user.username, created); + appendChatEntries(normalizedUser, [entry]); + movePeerToTop(normalizedUser); + } catch (error) { + const message = parseApiErrorMessage( + error, + `Failed to send message to @${normalizedUser}`, + ); + appendLines([{ type: "err", text: message }]); + } + }; + + const runCommand = async (raw: string) => { + const trimmed = raw.trim(); + if (!trimmed) { + return; + } + + if (activeChat) { + const loweredInput = trimmed.toLowerCase(); + if (loweredInput === "exit" || loweredInput === "/exit") { + appendLines([{ type: "out", text: `Closed chat @${activeChat}` }]); + setActiveChat(null); + return; + } + if (loweredInput === "help" || loweredInput === "/help") { + appendLines([ + { type: "out", text: "Chat mode commands:" }, + { type: "out", text: " /exit close current chat" }, + { type: "out", text: " /help show this help" }, + ]); + return; + } + + void handleSendChatMessage(activeChat, trimmed); + return; + } + + appendLines([{ type: "cmd", text: `$ ${trimmed}` }]); + + const [command, ...args] = trimmed.split(/\s+/); + const lowered = command.toLowerCase(); + + if (lowered === "help") { + appendLines(commandHelp.map((text) => ({ type: "out" as const, text }))); + return; + } + + if (lowered === "inbox") { + await renderInbox(); + return; + } + + if (lowered === "friends" || lowered === "lsfriends") { + if (!user?.username) { + appendLines([{ type: "err", text: "Sign in to load friends." }]); + return; + } + + try { + const following = await getUsersFollowingUsernames(user.username); + const peers = await getDirectChatPeers(user.username).catch(() => []); + const activeUsers = Array.from( + new Set([ + ...peers.map((name) => normalizeUsername(name)), + ...Object.keys(chatThreads), + ]), + ); + if (!following.length && !activeUsers.length) { + appendLines([ + { type: "out", text: "No friends or active chats yet." }, + ]); + return; + } + + if (following.length) { + appendLines([ + { type: "out", text: "Friends (following):" }, + ...following.map((name) => ({ + type: "out" as const, + text: `- @${name}`, + })), + ]); + } + + if (activeUsers.length) { + setChatPeers((prev) => + Array.from(new Set([...prev, ...activeUsers])), + ); + appendLines([ + { type: "out", text: "Active chat threads:" }, + ...activeUsers.map((name) => ({ + type: "out" as const, + text: `- @${name}`, + })), + ]); + } + } catch { + appendLines([{ type: "err", text: "Could not load friends list." }]); + } + return; + } + + if (lowered === "chat") { + const target = normalizeUsername(args[0] ?? ""); + if (!target) { + appendLines([{ type: "err", text: "Usage: chat " }]); + return; + } + await openChatSession(target); + return; + } + + if (lowered === "exit") { + appendLines([{ type: "err", text: "No active chat to exit." }]); + return; + } + + if (lowered === "clear") { + setLines([]); + return; + } + + if (lowered === "echo") { + appendLines([{ type: "out", text: args.join(" ") }]); + return; + } + + if (lowered === "status") { + appendLines([ + { type: "out", text: "session: active" }, + { + type: "out", + text: `chat: ${activeChat ? `@${activeChat}` : "none"}`, + }, + { type: "out", text: "notifications: enabled" }, + { type: "out", text: "messaging gateway: online" }, + ]); + return; + } + + if (lowered === "open") { + const target = (args[0] ?? "").toLowerCase(); + if (target === "home") { + appendLines([{ type: "out", text: "Opening home..." }]); + router.push("/(tabs)"); + return; + } + if (target === "explore") { + appendLines([{ type: "out", text: "Opening explore..." }]); + router.push("/(tabs)/explore"); + return; + } + appendLines([{ type: "err", text: "Usage: open home|explore" }]); + return; + } + + if (lowered === "msg") { + if (args.length < 2 || !args[0].startsWith("@")) { + appendLines([{ type: "err", text: "Usage: msg @user " }]); + return; + } + const target = args[0].slice(1); + const message = args.slice(1).join(" "); + if (!target || !message) { + appendLines([{ type: "err", text: "Usage: msg @user " }]); + return; + } + const normalizedTarget = normalizeUsername(target); + if (activeChat !== normalizedTarget) { + setActiveChat(normalizedTarget); + appendLines([ + { + type: "out", + text: `Chat mode: @${normalizedTarget} (type /exit to leave)`, + }, + ]); + void loadChatHistory(normalizedTarget); + } + movePeerToTop(normalizedTarget); + void handleSendChatMessage(normalizedTarget, message); + return; + } + + appendLines([{ type: "err", text: `Unknown command: ${command}` }]); + }; + + const handleSubmit = () => { + const suggestion = suggestions[0]; + const normalizedInput = input.trim().toLowerCase(); + if ( + suggestion && + suggestion.value.trim().toLowerCase() !== normalizedInput + ) { + setInput(suggestion.value); + return; + } + + const value = input; + setInput(""); + void runCommand(value); + }; + + useEffect(() => { + scheduleOutputScroll(true); + }, [lines, scheduleOutputScroll]); + + useEffect(() => { + if (!input.length) { + return; + } + scheduleOutputScroll(false); + }, [input, scheduleOutputScroll]); + + useEffect(() => { + return () => { + if (outputScrollRafRef.current !== null) { + cancelAnimationFrame(outputScrollRafRef.current); + outputScrollRafRef.current = null; + } + }; + }, []); + + useEffect(() => { + activeChatRef.current = activeChat; + }, [activeChat]); + + useEffect(() => { + const rawParam = params.chat; + const chatParam = Array.isArray(rawParam) + ? (rawParam[0] ?? "") + : (rawParam ?? ""); + const target = normalizeUsername(chatParam); + if (!target || !user?.username) { + return; + } + + if (autoOpenedChatRef.current === target) { + return; + } + + autoOpenedChatRef.current = target; + void openChatSession(target); + }, [openChatSession, params.chat, user?.username]); + + useEffect(() => { + if (!user?.username || !token) { + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + return; + } + + let cancelled = false; + let reconnectAttempt = 0; + + const connect = () => { + if (cancelled || !user.username || !token) { + return; + } + + const wsBase = getWebSocketBaseUrl(API_BASE_URL); + const url = `${wsBase}/messages/${encodeURIComponent(user.username)}/stream?token=${encodeURIComponent(token)}`; + const socket = new WebSocket(url); + wsRef.current = socket; + + socket.onopen = () => { + reconnectAttempt = 0; + }; + + socket.onmessage = (event) => { + const raw = typeof event.data === "string" ? event.data : ""; + if (!raw) { + return; + } + + try { + const payload = JSON.parse(raw) as DirectMessageStreamEvent; + if (payload.type !== "direct_message" || !payload.direct_message) { + return; + } + if (!user.username) { + return; + } + + const message = payload.direct_message; + const normalizedMe = normalizeUsername(user.username); + const sender = normalizeUsername(message.sender_name); + const recipient = normalizeUsername(message.recipient_name); + if (sender !== normalizedMe && recipient !== normalizedMe) { + return; + } + + const peer = getPeerForMessage(user.username, message); + if (!peer) { + return; + } + + const entry = mapMessageToChatEntry(user.username, message); + const appended = appendChatEntries(peer, [entry]); + if (!appended.length) { + return; + } + movePeerToTop(peer); + + if (entry.author === "them") { + notifyIncomingMessage(peer, entry.text); + } + + if (activeChatRef.current === peer && entry.author === "them") { + appendLines([ + { + type: "out", + chatRole: entry.author, + text: `[${entry.timestamp}] @${peer}: ${entry.text}`, + }, + ]); + return; + } + + if (entry.author === "them") { + appendLines([ + { + type: "out", + text: `New message from @${peer}: ${entry.text}`, + }, + ]); + } + } catch { + // Ignore malformed stream payloads. + } + }; + + socket.onclose = () => { + wsRef.current = null; + if (cancelled) { + return; + } + + reconnectAttempt += 1; + const backoffMs = Math.min(1000 * reconnectAttempt, 5000); + reconnectTimeoutRef.current = setTimeout(connect, backoffMs); + }; + + socket.onerror = () => { + socket.close(); + }; + }; + + connect(); + + return () => { + cancelled = true; + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + }; + }, [movePeerToTop, notifyIncomingMessage, token, user?.username]); + + useEffect(() => { + if (!activeChat || !user?.username) { + return; + } + + const interval = setInterval(() => { + void getDirectMessages(user.username, activeChat, 0, 200) + .then((messages) => { + const incoming = messages + .filter((message) => !seenMessageIdsRef.current.has(message.id)) + .map((message) => mapMessageToChatEntry(user.username, message)); + if (!incoming.length) { + return; + } + + const appended = appendChatEntries(activeChat, incoming); + if (!appended.length) { + return; + } + appended + .filter((entry) => entry.author === "them") + .forEach((entry) => { + notifyIncomingMessage(activeChat, entry.text); + }); + appendLines( + appended.map((entry) => ({ + type: "out" as const, + chatRole: entry.author, + text: + entry.author === "me" + ? `[${entry.timestamp}] you: ${entry.text}` + : `[${entry.timestamp}] @${activeChat}: ${entry.text}`, + })), + ); + }) + .catch((error) => { + if (isMissingDirectMessageUserError(error)) { + appendLines([ + { + type: "err", + text: `Chat closed: @${activeChat} no longer exists.`, + }, + ]); + setActiveChat((current) => + current === activeChat ? null : current, + ); + } + }); + }, 3000); + + return () => { + clearInterval(interval); + }; + }, [activeChat, notifyIncomingMessage, user?.username]); + + return ( + + + + + router.back()} style={styles.backButton}> + + + Terminal + + + + + + + + {activeChat ? `chat @${activeChat}` : "command mode"} + + + peers {chatPeers.length} + + + + scheduleOutputScroll(true)} + > + {lines.map((line) => ( + + {line.text} + + ))} + + + {suggestions.length ? ( + + {suggestions.map((suggestion, index) => ( + setInput(suggestion.value)} + style={[ + styles.suggestionItem, + index === suggestions.length - 1 && + styles.suggestionItemLast, + { + borderColor: colors.border, + }, + ]} + > + + {suggestion.label} + + + ))} + + ) : null} + + + scheduleOutputScroll(true)} + onSubmitEditing={handleSubmit} + placeholder={ + activeChat + ? `Message @${activeChat} (/exit to close)` + : "Enter command" + } + placeholderTextColor={colors.muted} + style={[styles.input, { color: colors.text }]} + autoCapitalize="none" + autoCorrect={false} + returnKeyType="send" + /> + + + + + + + + ); +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + }, + header: { + marginHorizontal: 16, + marginTop: 8, + borderWidth: 1, + borderRadius: 12, + paddingHorizontal: 12, + paddingVertical: 10, + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + }, + backButton: { + width: 28, + height: 28, + borderRadius: 14, + alignItems: "center", + justifyContent: "center", + }, + headerDotWrap: { + width: 28, + height: 28, + alignItems: "center", + justifyContent: "center", + }, + headerDot: { + width: 8, + height: 8, + borderRadius: 4, + }, + outputWrap: { + flex: 1, + marginHorizontal: 16, + marginTop: 10, + borderWidth: 1, + borderRadius: 12, + paddingHorizontal: 10, + paddingVertical: 10, + }, + outputContent: { + gap: 6, + paddingTop: 2, + paddingBottom: 16, + }, + statusStrip: { + marginHorizontal: 16, + marginTop: 8, + borderWidth: 1, + borderRadius: 10, + paddingHorizontal: 10, + paddingVertical: 6, + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + }, + suggestionWrap: { + marginHorizontal: 16, + borderWidth: 1, + borderRadius: 10, + marginBottom: 8, + overflow: "hidden", + }, + suggestionItem: { + paddingHorizontal: 10, + paddingVertical: 8, + borderBottomWidth: 1, + }, + suggestionItemLast: { + borderBottomWidth: 0, + }, + line: { + fontFamily: "SpaceMono", + fontSize: 13, + lineHeight: 18, + }, + inputRow: { + marginHorizontal: 16, + borderWidth: 1, + borderRadius: 12, + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 10, + paddingVertical: 8, + gap: 10, + }, + input: { + flex: 1, + fontFamily: "SpaceMono", + fontSize: 14, + }, + sendButton: { + width: 30, + height: 30, + borderRadius: 8, + alignItems: "center", + justifyContent: "center", + }, +}); diff --git a/frontend/app/user/[username].tsx b/frontend/app/user/[username].tsx new file mode 100644 index 0000000..eb8b72d --- /dev/null +++ b/frontend/app/user/[username].tsx @@ -0,0 +1,789 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + Animated, + FlatList, + Modal, + Pressable, + RefreshControl, + StyleSheet, + TextInput, + View, +} from "react-native"; +import { + SafeAreaView, + useSafeAreaInsets, +} from "react-native-safe-area-context"; +import { useFocusEffect } from "@react-navigation/native"; +import { useLocalSearchParams, useRouter } from "expo-router"; +import { UserProps } from "@/constants/Types"; +import { ProjectCard } from "@/components/ProjectCard"; +import { SectionHeader } from "@/components/SectionHeader"; +import { StatPill } from "@/components/StatPill"; +import { ThemedText } from "@/components/ThemedText"; +import { UserCard } from "@/components/UserCard"; +import { Post } from "@/components/Post"; +import User from "@/components/User"; +import { TopBlur } from "@/components/TopBlur"; +import { FloatingScrollTopButton } from "@/components/FloatingScrollTopButton"; +import { useAutoRefresh } from "@/hooks/useAutoRefresh"; +import { useAppColors } from "@/hooks/useAppColors"; +import { useMotionConfig } from "@/hooks/useMotionConfig"; +import { useTopBlurScroll } from "@/hooks/useTopBlurScroll"; +import { useAuth } from "@/contexts/AuthContext"; +import { + clearApiCache, + followUser, + getPostsByUserId, + getProjectById, + getProjectBuilders, + getProjectsByBuilderId, + getProjectsByUserId, + getUserByUsername, + getUsersFollowers, + getUsersFollowersUsernames, + getUsersFollowing, + getUsersFollowingUsernames, + unfollowUser, +} from "@/services/api"; +import { mapPostToUi, mapProjectToUi } from "@/services/mappers"; +import { subscribeToPostEvents } from "@/services/postEvents"; + +export default function UserProfileScreen() { + const colors = useAppColors(); + const insets = useSafeAreaInsets(); + const router = useRouter(); + const { username } = useLocalSearchParams<{ username?: string }>(); + const { user: authUser } = useAuth(); + const [profileUser, setProfileUser] = useState(null); + const [projects, setProjects] = useState( + [] as ReturnType[], + ); + const [posts, setPosts] = useState([] as ReturnType[]); + const [followersCount, setFollowersCount] = useState(0); + const [followingCount, setFollowingCount] = useState(0); + const [shipsCount, setShipsCount] = useState(0); + const [isLoading, setIsLoading] = useState(true); + const [hasError, setHasError] = useState(false); + const [isFollowing, setIsFollowing] = useState(false); + const [isUpdatingFollow, setIsUpdatingFollow] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + const [isFollowersOpen, setIsFollowersOpen] = useState(false); + const [isFollowingOpen, setIsFollowingOpen] = useState(false); + const [isFollowersLoading, setIsFollowersLoading] = useState(false); + const [isFollowingLoading, setIsFollowingLoading] = useState(false); + const [followerUsers, setFollowerUsers] = useState([]); + const [followingUsers, setFollowingUsers] = useState([]); + const [followingSet, setFollowingSet] = useState>(new Set()); + const [isFollowingBusy, setIsFollowingBusy] = useState(false); + const [followersQuery, setFollowersQuery] = useState(""); + const [followingQuery, setFollowingQuery] = useState(""); + const motion = useMotionConfig(); + const reveal = useRef(new Animated.Value(0.08)).current; + const hasLoadedRef = useRef(false); + const scrollRef = useRef(null); + const { scrollY, onScroll } = useTopBlurScroll(); + + const filteredFollowerUsers = useMemo(() => { + const trimmed = followersQuery.trim().toLowerCase(); + if (!trimmed) { + return followerUsers; + } + return followerUsers.filter((user) => + user.username.toLowerCase().includes(trimmed), + ); + }, [followerUsers, followersQuery]); + + const filteredFollowingUsers = useMemo(() => { + const trimmed = followingQuery.trim().toLowerCase(); + if (!trimmed) { + return followingUsers; + } + return followingUsers.filter((user) => + user.username.toLowerCase().includes(trimmed), + ); + }, [followingUsers, followingQuery]); + + useEffect(() => { + if (motion.prefersReducedMotion) { + reveal.setValue(1); + return; + } + + Animated.timing(reveal, { + toValue: 1, + duration: motion.duration(420), + useNativeDriver: true, + }).start(); + }, [motion, reveal]); + + const loadUser = useCallback( + async (options?: { silent?: boolean }) => { + const isSilent = options?.silent; + const shouldShowLoader = !isSilent && !hasLoadedRef.current; + if (!username || Array.isArray(username)) { + return; + } + if (shouldShowLoader) { + setIsLoading(true); + } + try { + const userData = await getUserByUsername(username); + setProfileUser(userData); + + const isSelf = + !!authUser?.username && authUser.username === userData.username; + const loadProjects = async () => { + if (!userData.id) { + return []; + } + if (isSelf) { + try { + return await getProjectsByBuilderId(userData.id); + } catch { + return await getProjectsByUserId(userData.id); + } + } + return await getProjectsByUserId(userData.id); + }; + + const [userProjects, userPosts, followers, followingIds, following] = + await Promise.all([ + loadProjects().catch(() => []), + userData.id ? getPostsByUserId(userData.id) : Promise.resolve([]), + getUsersFollowers(userData.username).catch(() => []), + authUser?.username + ? getUsersFollowing(authUser.username).catch(() => []) + : Promise.resolve([]), + getUsersFollowing(userData.username).catch(() => []), + ]); + + const safeProjects = Array.isArray(userProjects) ? userProjects : []; + const safePosts = Array.isArray(userPosts) ? userPosts : []; + const safeFollowers = Array.isArray(followers) ? followers : []; + const safeFollowing = Array.isArray(followingIds) ? followingIds : []; + const safeProfileFollowing = Array.isArray(following) ? following : []; + + const projectMap = new Map( + safeProjects.map((project) => [project.id, project]), + ); + + const builderCounts = await Promise.all( + safeProjects.map((project) => + getProjectBuilders(project.id).catch(() => []), + ), + ); + setProjects( + safeProjects.map((project, index) => + mapProjectToUi(project, builderCounts[index]?.length ?? 0), + ), + ); + setShipsCount(safePosts.length); + setFollowersCount(safeFollowers.length); + setFollowingCount(safeProfileFollowing.length); + setPosts( + await Promise.all( + safePosts.slice(0, 4).map(async (post) => { + const project = projectMap.get(post.project) + ? Promise.resolve(projectMap.get(post.project)!) + : getProjectById(post.project).catch(() => null); + return mapPostToUi(post, userData, await project); + }), + ), + ); + + setIsFollowing(safeFollowing.includes(userData.id)); + setHasError(false); + } catch { + if (!isSilent) { + setProfileUser(null); + setProjects([]); + setPosts([]); + setFollowersCount(0); + setFollowingCount(0); + setShipsCount(0); + } + setHasError(true); + } finally { + hasLoadedRef.current = true; + if (shouldShowLoader) { + setIsLoading(false); + } + } + }, + [authUser?.username, username], + ); + + useEffect(() => { + clearApiCache(); + loadUser(); + }, [loadUser]); + + useEffect(() => { + return subscribeToPostEvents((event) => { + setPosts((prev) => { + if (event.type === "updated") { + return prev.map((post) => + post.id === event.postId + ? { + ...post, + content: event.content, + media: event.media ?? post.media, + } + : post, + ); + } + if (event.type === "stats") { + return prev.map((post) => + post.id === event.postId + ? { + ...post, + likes: event.likes ?? post.likes, + comments: event.comments ?? post.comments, + } + : post, + ); + } + if (event.type === "deleted") { + return prev.filter((post) => post.id !== event.postId); + } + return prev; + }); + }); + }, []); + + useFocusEffect( + useCallback(() => { + clearApiCache(); + loadUser({ silent: true }); + }, [loadUser]), + ); + + const handleRefresh = useCallback(async () => { + setIsRefreshing(true); + clearApiCache(); + await loadUser({ silent: true }); + setIsRefreshing(false); + }, [loadUser]); + + useAutoRefresh(() => loadUser({ silent: true }), { focusRefresh: false }); + + const loadFollowers = useCallback(async () => { + if (!profileUser?.username) { + return; + } + setIsFollowersLoading(true); + try { + const list = await getUsersFollowersUsernames(profileUser.username); + const names = Array.isArray(list) ? list : []; + const users = await Promise.all( + names.map((name) => getUserByUsername(name).catch(() => null)), + ); + setFollowerUsers(users.filter((item): item is UserProps => !!item)); + if (authUser?.username) { + const followingIds = await getUsersFollowing(authUser.username).catch( + () => [], + ); + setFollowingSet( + new Set(Array.isArray(followingIds) ? followingIds : []), + ); + } + } finally { + setIsFollowersLoading(false); + } + }, [authUser?.username, profileUser?.username]); + + const loadFollowing = useCallback(async () => { + if (!profileUser?.username) { + return; + } + setIsFollowingLoading(true); + try { + const list = await getUsersFollowingUsernames(profileUser.username); + const names = Array.isArray(list) ? list : []; + const users = await Promise.all( + names.map((name) => getUserByUsername(name).catch(() => null)), + ); + setFollowingUsers(users.filter((item): item is UserProps => !!item)); + if (authUser?.username) { + const followingIds = await getUsersFollowing(authUser.username).catch( + () => [], + ); + setFollowingSet( + new Set(Array.isArray(followingIds) ? followingIds : []), + ); + } + } finally { + setIsFollowingLoading(false); + } + }, [authUser?.username, profileUser?.username]); + + const handleToggleModalFollow = async (target: UserProps) => { + if (!authUser?.username || isFollowingBusy) { + return; + } + const targetId = target.id ?? -1; + const isCurrentlyFollowing = followingSet.has(targetId); + setIsFollowingBusy(true); + try { + if (isCurrentlyFollowing) { + await unfollowUser(authUser.username, target.username); + } else { + await followUser(authUser.username, target.username); + } + setFollowingSet((prev) => { + const next = new Set(prev); + if (isCurrentlyFollowing) { + next.delete(targetId); + } else if (targetId > 0) { + next.add(targetId); + } + return next; + }); + } finally { + setIsFollowingBusy(false); + } + }; + + const handleToggleFollow = async () => { + if (!authUser?.username || !profileUser || isUpdatingFollow) { + return; + } + setIsUpdatingFollow(true); + try { + if (isFollowing) { + await unfollowUser(authUser.username, profileUser.username); + setIsFollowing(false); + setFollowersCount((prev) => Math.max(0, prev - 1)); + } else { + await followUser(authUser.username, profileUser.username); + setIsFollowing(true); + setFollowersCount((prev) => prev + 1); + } + } finally { + setIsUpdatingFollow(false); + } + }; + + const canFollow = useMemo( + () => authUser?.username && profileUser?.username !== authUser.username, + [authUser?.username, profileUser?.username], + ); + + return ( + + + + + } + contentContainerStyle={[ + styles.container, + { paddingTop: 8, paddingBottom: 96 + insets.bottom }, + ]} + > + + + {profileUser?.username || "Profile"} + + + Builder profile and streams. + + + + + {isLoading ? ( + + + + + + ) : profileUser ? ( + + + {canFollow ? ( + + + {isFollowing ? "Following" : "Follow"} + + + ) : null} + + ) : ( + + + {hasError ? "Unable to load profile." : "User not found."} + + + )} + + + + + + + { + setIsFollowersOpen(true); + loadFollowers(); + }} + > + + + { + setIsFollowingOpen(true); + loadFollowing(); + }} + > + + + + + + + + {projects.length ? ( + + {projects.map((project) => ( + + ))} + + ) : ( + + + {hasError ? "Streams unavailable." : "No streams yet."} + + + )} + + + + + {posts.length ? ( + posts.map((post) => ) + ) : ( + + + {hasError ? "Bytes unavailable." : "No bytes yet."} + + + )} + + + + scrollRef.current?.scrollTo({ y: 0, animated: true })} + /> + + setIsFollowersOpen(false)} + > + setIsFollowersOpen(false)} + > + + Followers + + item.username} + renderItem={({ item }) => ( + { + setIsFollowersOpen(false); + router.push({ + pathname: "/user/[username]", + params: { username: item.username }, + }); + }} + onToggleFollow={() => handleToggleModalFollow(item)} + /> + )} + ItemSeparatorComponent={() => } + ListEmptyComponent={ + isFollowersLoading ? ( + + Loading... + + ) : followersQuery.trim() ? ( + + No matches found. + + ) : ( + + No followers yet. + + ) + } + contentContainerStyle={styles.modalList} + removeClippedSubviews + initialNumToRender={8} + maxToRenderPerBatch={8} + windowSize={7} + updateCellsBatchingPeriod={50} + /> + + + + setIsFollowingOpen(false)} + > + setIsFollowingOpen(false)} + > + + Following + + item.username} + renderItem={({ item }) => ( + { + setIsFollowingOpen(false); + router.push({ + pathname: "/user/[username]", + params: { username: item.username }, + }); + }} + onToggleFollow={() => handleToggleModalFollow(item)} + /> + )} + ItemSeparatorComponent={() => } + ListEmptyComponent={ + isFollowingLoading ? ( + + Loading... + + ) : followingQuery.trim() ? ( + + No matches found. + + ) : ( + + Not following anyone yet. + + ) + } + contentContainerStyle={styles.modalList} + removeClippedSubviews + initialNumToRender={8} + maxToRenderPerBatch={8} + windowSize={7} + updateCellsBatchingPeriod={50} + /> + + + + + ); +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + }, + safeArea: { + flex: 1, + }, + background: { + ...StyleSheet.absoluteFillObject, + }, + container: { + paddingVertical: 16, + paddingHorizontal: 16, + gap: 16, + paddingTop: 0, + }, + title: { + fontSize: 26, + lineHeight: 30, + }, + profileCard: { + borderRadius: 16, + padding: 14, + borderWidth: 1, + gap: 12, + }, + profileContent: { + gap: 12, + }, + statsRow: { + flexDirection: "row", + gap: 10, + }, + projectGrid: { + gap: 12, + }, + modalBackdrop: { + flex: 1, + backgroundColor: "rgba(0, 0, 0, 0.6)", + alignItems: "center", + justifyContent: "center", + padding: 24, + }, + modalCard: { + width: "100%", + maxHeight: "70%", + borderRadius: 16, + padding: 16, + gap: 12, + }, + modalList: { + paddingBottom: 8, + }, + modalRow: { + paddingVertical: 8, + }, + searchInput: { + borderWidth: 1, + borderRadius: 12, + paddingHorizontal: 12, + paddingVertical: 8, + fontSize: 14, + }, + emptyState: { + alignItems: "center", + paddingVertical: 16, + }, + followButton: { + alignSelf: "flex-start", + borderRadius: 12, + paddingVertical: 10, + paddingHorizontal: 16, + }, + skeletonStack: { + gap: 10, + }, + skeletonAvatar: { + width: 64, + height: 64, + borderRadius: 32, + }, + skeletonLine: { + height: 12, + borderRadius: 6, + }, + skeletonLineShort: { + height: 12, + borderRadius: 6, + width: "60%", + }, +}); diff --git a/frontend/app/welcome.tsx b/frontend/app/welcome.tsx new file mode 100644 index 0000000..f81d55c --- /dev/null +++ b/frontend/app/welcome.tsx @@ -0,0 +1,299 @@ +import React, { useRef, useState } from "react"; +import { + Animated, + FlatList, + Pressable, + StyleSheet, + useWindowDimensions, + View, + type NativeScrollEvent, + type NativeSyntheticEvent, +} from "react-native"; +import { + SafeAreaView, + useSafeAreaInsets, +} from "react-native-safe-area-context"; +import { useLocalSearchParams, useRouter } from "expo-router"; +import { ThemedText } from "@/components/ThemedText"; +import { useAuth } from "@/contexts/AuthContext"; +import { usePreferences } from "@/contexts/PreferencesContext"; +import { useAppColors } from "@/hooks/useAppColors"; +import { useMotionConfig } from "@/hooks/useMotionConfig"; + +type Slide = { + id: string; + title: string; + body: string; + bulletA: string; + bulletB: string; + bulletC: string; +}; + +const slides: Slide[] = [ + { + id: "welcome", + title: "Welcome to DevBits", + body: "A fast social builder space for sharing work, progress, and ideas.", + bulletA: "Streams = projects", + bulletB: "Bytes = posts", + bulletC: + "Profiles, follows, saves, comments, and DMs keep everything connected", + }, + { + id: "streams", + title: "Streams are your projects", + body: "Create streams for products, experiments, or learning journeys.", + bulletA: "Describe the goal and context", + bulletB: "Add builders and collaborators", + bulletC: "Each stream has its own timeline and discussion", + }, + { + id: "bytes", + title: "Bytes are your posts", + body: "Publish quick updates, demos, media, and notes as bytes.", + bulletA: "Post to a stream to keep progress organized", + bulletB: "People can save, comment, and react", + bulletC: "Use bytes for daily updates or major milestones", + }, + { + id: "navigate", + title: "How to navigate", + body: "Use tabs for discovery, profile, and your main feed.", + bulletA: "Explore: find builders, streams, and bytes", + bulletB: "Terminal: handle direct messages and commands", + bulletC: + "Settings: customize profile, preferences, and open this tour anytime", + }, +]; + +export default function WelcomeScreen() { + const colors = useAppColors(); + const motion = useMotionConfig(); + const { width } = useWindowDimensions(); + const insets = useSafeAreaInsets(); + const { mode } = useLocalSearchParams<{ mode?: string }>(); + const router = useRouter(); + const { acknowledgeSignUp } = useAuth(); + const { updatePreferences } = usePreferences(); + const [index, setIndex] = useState(0); + const listRef = useRef | null>(null); + const scrollX = useRef(new Animated.Value(0)).current; + + const isFirstRun = String(mode ?? "") === "first-run"; + + const completeTour = async () => { + await updatePreferences({ hasSeenWelcomeTour: true }); + acknowledgeSignUp(); + router.replace("/(tabs)"); + }; + + const skipTour = async () => { + if (isFirstRun) { + await completeTour(); + return; + } + router.back(); + }; + + const nextSlide = () => { + if (index >= slides.length - 1) { + void completeTour(); + return; + } + listRef.current?.scrollToIndex({ index: index + 1, animated: true }); + }; + + const onMomentumEnd = (event: NativeSyntheticEvent) => { + const nextIndex = Math.round(event.nativeEvent.contentOffset.x / width); + setIndex(Math.max(0, Math.min(slides.length - 1, nextIndex))); + }; + + const slideRender = ({ + item, + index: itemIndex, + }: { + item: Slide; + index: number; + }) => { + const inputRange = [ + (itemIndex - 1) * width, + itemIndex * width, + (itemIndex + 1) * width, + ]; + const opacity = motion.prefersReducedMotion + ? 1 + : scrollX.interpolate({ + inputRange, + outputRange: [0.9, 1, 0.9], + extrapolate: "clamp", + }); + const translateY = motion.prefersReducedMotion + ? 0 + : scrollX.interpolate({ + inputRange, + outputRange: [8, 0, 8], + extrapolate: "clamp", + }); + + return ( + + + + DevBits Tour + + {item.title} + + {item.body} + + + + {[item.bulletA, item.bulletB, item.bulletC].map((point) => ( + + + {point} + + ))} + + + + ); + }; + + return ( + + + + + {index + 1} / {slides.length} + + + {isFirstRun ? "Skip" : "Close"} + + + + item.id} + renderItem={slideRender} + horizontal + pagingEnabled + bounces={false} + showsHorizontalScrollIndicator={false} + onMomentumScrollEnd={onMomentumEnd} + onScroll={Animated.event( + [{ nativeEvent: { contentOffset: { x: scrollX } } }], + { useNativeDriver: false }, + )} + scrollEventThrottle={16} + /> + + + + {slides.map((slide, dotIndex) => { + const isActive = dotIndex === index; + return ( + + ); + })} + + + + + {index === slides.length - 1 ? "Start building" : "Next"} + + + + + + ); +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + }, + topRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: 16, + }, + slide: { + paddingHorizontal: 16, + justifyContent: "center", + }, + card: { + borderWidth: 1, + borderRadius: 20, + padding: 18, + gap: 10, + }, + listWrap: { + marginTop: 8, + gap: 10, + }, + listRow: { + flexDirection: "row", + alignItems: "flex-start", + gap: 10, + }, + dot: { + width: 8, + height: 8, + borderRadius: 4, + marginTop: 7, + }, + bottomArea: { + paddingHorizontal: 16, + gap: 12, + }, + pagination: { + flexDirection: "row", + justifyContent: "center", + gap: 8, + alignItems: "center", + }, + paginationDot: { + height: 8, + borderRadius: 999, + }, + cta: { + borderRadius: 14, + paddingVertical: 13, + alignItems: "center", + }, +}); diff --git a/frontend/assets/images/Devbits_Icons.png b/frontend/assets/images/Devbits_Icons.png new file mode 100644 index 0000000..de679af Binary files /dev/null and b/frontend/assets/images/Devbits_Icons.png differ diff --git a/frontend/assets/images/Devbits_Icons1.png b/frontend/assets/images/Devbits_Icons1.png new file mode 100644 index 0000000..6dc7044 Binary files /dev/null and b/frontend/assets/images/Devbits_Icons1.png differ diff --git a/frontend/assets/images/adaptive-icon.png b/frontend/assets/images/adaptive-icon.png index 03d6f6b..de679af 100644 Binary files a/frontend/assets/images/adaptive-icon.png and b/frontend/assets/images/adaptive-icon.png differ diff --git a/frontend/assets/images/adaptive-icon1.png b/frontend/assets/images/adaptive-icon1.png new file mode 100644 index 0000000..6dc7044 Binary files /dev/null and b/frontend/assets/images/adaptive-icon1.png differ diff --git a/frontend/assets/images/icon.png b/frontend/assets/images/icon.png index a0b1526..de679af 100644 Binary files a/frontend/assets/images/icon.png and b/frontend/assets/images/icon.png differ diff --git a/frontend/assets/images/icon1.png b/frontend/assets/images/icon1.png new file mode 100644 index 0000000..6dc7044 Binary files /dev/null and b/frontend/assets/images/icon1.png differ diff --git a/frontend/assets/images/partial-react-logo.png b/frontend/assets/images/partial-react-logo.png deleted file mode 100644 index 66fd957..0000000 Binary files a/frontend/assets/images/partial-react-logo.png and /dev/null differ diff --git a/frontend/assets/images/react-logo.png b/frontend/assets/images/react-logo.png deleted file mode 100644 index 9d72a9f..0000000 Binary files a/frontend/assets/images/react-logo.png and /dev/null differ diff --git a/frontend/assets/images/react-logo@2x.png b/frontend/assets/images/react-logo@2x.png deleted file mode 100644 index 2229b13..0000000 Binary files a/frontend/assets/images/react-logo@2x.png and /dev/null differ diff --git a/frontend/assets/images/react-logo@3x.png b/frontend/assets/images/react-logo@3x.png deleted file mode 100644 index a99b203..0000000 Binary files a/frontend/assets/images/react-logo@3x.png and /dev/null differ diff --git a/frontend/assets/images/splash-icon.png b/frontend/assets/images/splash-icon.png index 03d6f6b..de679af 100644 Binary files a/frontend/assets/images/splash-icon.png and b/frontend/assets/images/splash-icon.png differ diff --git a/frontend/assets/images/splash-icon1.png b/frontend/assets/images/splash-icon1.png new file mode 100644 index 0000000..6dc7044 Binary files /dev/null and b/frontend/assets/images/splash-icon1.png differ diff --git a/frontend/babel.config.js b/frontend/babel.config.js new file mode 100644 index 0000000..17131ee --- /dev/null +++ b/frontend/babel.config.js @@ -0,0 +1,7 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: ["babel-preset-expo"], + plugins: ["react-native-reanimated/plugin"], + }; +}; diff --git a/frontend/components/BootScreen.tsx b/frontend/components/BootScreen.tsx new file mode 100644 index 0000000..00aa7c1 --- /dev/null +++ b/frontend/components/BootScreen.tsx @@ -0,0 +1,331 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { Animated, ScrollView, StyleSheet, Text, View } from "react-native"; + +type BootScreenProps = { + onDone: () => void; +}; + +type Phase = "boot"; + +const RAW_BOOT_LOG = `Loading Linux /vmlinuz-linux ... +Loading initial ramdisk /initramfs-linux.img ... +[ 0.000000] Linux version 6.9.0-arch1-1 (linux@archlinux) (gcc (GCC) 14.1.1 20240507, GNU ld (GNU Binutils) 2.42.0) #1 SMP PREEMPT_DYNAMIC Thu, 23 May 2024 18:15:20 +0000 +[ 0.000000] Command line: BOOT_IMAGE=/vmlinuz-linux root=UUID=3c7e2a4f-9b1d-4e8a-a3c2-7f8e4d2b1a9c rw loglevel=3 quiet +[ 0.000000] x86/fpu: Supporting XSAVE feature 0x001: 'x87 floating point registers' +[ 0.000000] x86/fpu: Supporting XSAVE feature 0x002: 'SSE registers' +[ 0.000000] x86/fpu: Supporting XSAVE feature 0x004: 'AVX registers' +[ 0.000000] x86/fpu: xstate_offset[2]: 576, xstate_sizes[2]: 256 +[ 0.000000] x86/fpu: Enabled xstate features 0x7, context size is 832 bytes +[ 0.000000] signal: max sigframe size: 1776 +[ 0.000000] BIOS-provided physical RAM map: +[ 0.000000] BIOS-e820: [mem 0x0000000000000000-0x000000000009efff] usable +[ 0.000000] BIOS-e820: [mem 0x000000000009f000-0x00000000000fffff] reserved +[ 0.000000] BIOS-e820: [mem 0x0000000000100000-0x00000000cafbefff] usable +[ 0.000000] BIOS-e820: [mem 0x00000000cafbf000-0x00000000cd98efff] reserved +[ 0.000000] NX (Execute Disable) protection: active +[ 0.000000] efi: EFI v2.70 by American Megatrends +[ 0.000000] SMBIOS 3.3.0 present. +[ 0.000000] DMI: ASUS ROG Zephyrus G14 GA401QM/GA401QM, BIOS GA401QM.316 03/21/2024 +[ 0.000000] tsc: Fast TSC calibration using PIT +[ 0.000000] tsc: Detected 3294.789 MHz processor +[ 0.000004] e820: update [mem 0x00000000-0x00000fff] usable ==> reserved +[ 0.000011] last_pfn = 0x22f000 max_arch_pfn = 0x400000000 +[ 0.000267] Using GB pages for direct mapping +[ 0.000741] Secure boot disabled +[ 0.000742] RAMDISK: [mem 0x36b2e000-0x37a8efff] +[ 0.006466] Zone ranges: +[ 0.006467] DMA [mem 0x0000000000001000-0x0000000000ffffff] +[ 0.006469] DMA32 [mem 0x0000000001000000-0x00000000ffffffff] +[ 0.013767] ACPI: PM-Timer IO Port: 0x808 +[ 0.013843] IOAPIC[0]: apic_id 9, version 33, address 0xfec00000, GSI 0-23 +[ 0.019874] setup_percpu: NR_CPUS:320 nr_cpumask_bits:16 nr_cpu_ids:16 nr_node_ids:1 +[ 0.021597] Dentry cache hash table entries: 2097152 (order: 12, 16777216 bytes) +[ 0.022194] Inode-cache hash table entries: 1048576 (order: 11, 8388608 bytes) +[ 0.056936] Memory: 8192528K/8388608K available (16384K kernel code, 2135K rwdata, 13248K rodata, 3396K init, 3452K bss, 196080K reserved) +[ 0.065688] rcu: Preemptible hierarchical RCU implementation. +[ 0.069942] printk: console [tty0] enabled +[ 0.076676] pid_max: default: 32768 minimum: 301 +[ 0.078709] landlock: Up and running. +[ 0.079133] CPU0: Thermal monitoring enabled (TM1) +[ 0.079179] Spectre V1 : Mitigation: usercopy/swapgs barriers and __user pointer sanitization +[ 0.081955] Freeing SMP alternatives memory: 40K +[ 0.186424] smpboot: CPU0: AMD Ryzen 7 5800U with Radeon Graphics (family: 0x19, model: 0x50, stepping: 0x0) +[ 0.187845] smp: Bringing up secondary CPUs ... +[ 0.212076] smp: Brought up 1 node, 16 CPUs +[ 0.215199] devtmpfs: initialized +[ 0.216495] cpuidle: using governor menu +[ 0.246859] pci 0000:00:00.0: [1022:1630] type 00 class 0x060000 +[ 0.256237] SCSI subsystem initialized +[ 0.256237] libata version 3.00 loaded. +[ 0.271223] Trying to unpack rootfs image as initramfs... +:: Running early hook [udev] +[ 0.272458] Initialise system trusted keyrings +[ 0.280025] Block layer SCSI generic (bsg) driver version 0.4 loaded (major 242) +[ 0.291907] Non-volatile memory driver v1.3 +:: Running hook [udev] +:: Triggering uevents... +[ 0.305313] ahci 0000:00:08.0: AHCI 0001.0301 32 slots 1 ports 6 Gbps 0x1 impl SATA mode +[ 0.333358] NET: Registered PF_INET protocol family +[ 0.617619] ata1: SATA link up 6.0 Gbps (SStatus 133 SControl 300) +[ 0.644076] sda: sda1 sda2 sda3 +[ 0.658343] nvme nvme0: pci function 0000:01:00.0 +[ 0.843285] random: crng init done +[ 1.612929] EXT4-fs (nvme0n1p2): mounted filesystem 3c7e2a4f-9b1d-4e8a-a3c2-7f8e4d2b1a9c ro with ordered data mode. Quota mode: none. +[ 1.724943] systemd[1]: Detected architecture x86-64. +[ 1.863099] systemd[1]: Starting systemd-journald.service - Journal Service... +[ 1.886273] systemd[1]: Started systemd-journald.service - Journal Service. +[ 2.078912] EXT4-fs (nvme0n1p2): re-mounted 3c7e2a4f-9b1d-4e8a-a3c2-7f8e4d2b1a9c r/w. Quota mode: none. +[ 2.385373] iwlwifi 0000:03:00.0: loaded firmware version 72.daa05125.0 QuZ-a0-hr-b0-72.ucode op_mode iwlmvm +[ 2.701458] iwlwifi 0000:03:00.0: Detected Intel(R) Wi-Fi 6 AX200 160MHz, REV=0x340 +[ 3.156913] [drm] amdgpu kernel modesetting enabled. +[ 3.385181] Console: switching to colour frame buffer device 240x67 +[ OK ] Started udev Kernel Device Manager. + Starting Coldplug All udev Devices... +[ OK ] Finished Coldplug All udev Devices. + Starting Load Kernel Modules... +[ OK ] Finished Load Kernel Modules. + Starting Apply Kernel Variables... +[ OK ] Finished Apply Kernel Variables. + Starting Device Node Population... +[ OK ] Finished Device Node Population. + Starting Apply Kernel Configuration... +[ OK ] Finished Apply Kernel Configuration. + Starting File System Check on /dev/nvme0n1p2... +[ OK ] Finished File System Check on /dev/nvme0n1p2. + Starting Create Volatile Files and Directories... +[ OK ] Finished Create Volatile Files and Directories. + Starting Network Time Synchronization... +[ OK ] Started Network Time Synchronization. +[ OK ] Reached target System Time Set. + Starting Update UTMP about System Boot/Shutdown... +[ OK ] Finished Update UTMP about System Boot/Shutdown. + Mounting /boot... +[ OK ] Mounted /boot. +[ OK ] Reached target Local File Systems. + Starting Flush Journal to Persistent Storage... +[ OK ] Finished Flush Journal to Persistent Storage. + Starting Load/Save OS Random Seed... +[ OK ] Finished Load/Save OS Random Seed. + Starting Journal Service... +[ OK ] Started Journal Service. +[ OK ] Created slice User and Session Slice. +[ OK ] Reached target Slice Units. +[ OK ] Listening on D-Bus System Message Bus Socket. + Starting Network Manager... +[ OK ] Started Network Manager. + Starting Hostname Service... +[ OK ] Started Hostname Service. + Starting Bluetooth service... +[ OK ] Started Bluetooth service. + Starting Login Service... +[ OK ] Started Login Service. +[ OK ] Reached target Multi-User System. +[ OK ] Reached target Graphical Interface. + +Arch Linux 6.9.0-arch1-1 (tty1) + +archlinux login: _`; + +const TERM = { + bg: "#000000", + fg: "#c0c0c0", + bright: "#ffffff", + ok: "#55ff55", + warn: "#ffff55", + fail: "#ff5555", +}; + +function LineText({ line }: { line: string }) { + const isOk = line.startsWith("[ OK ]"); + const isWarn = line.startsWith("[ WARN ]"); + const isFail = line.startsWith("[FAILED]"); + + if (isOk || isWarn || isFail) { + const status = isOk ? "[ OK ]" : isWarn ? "[ WARN ]" : "[FAILED]"; + const rest = line.slice(status.length); + return ( + + + {status} + + {rest} + + ); + } + + if (line.startsWith("::")) { + return {line}; + } + + return {line}; +} + +export function BootScreen({ onDone }: BootScreenProps) { + const [phase] = useState("boot"); + const [visibleLines, setVisibleLines] = useState([]); + const [showPromptCursor, setShowPromptCursor] = useState(true); + const containerOpacity = useRef(new Animated.Value(1)).current; + const scrollRef = useRef(null); + const lines = useMemo( + () => + RAW_BOOT_LOG.split("\n") + .map((line) => line.trimEnd()) + .filter((line) => line.length > 0 || line === ""), + [], + ); + const finishRef = useRef(false); + + const finishBoot = React.useCallback(() => { + if (finishRef.current) { + return; + } + finishRef.current = true; + onDone(); + }, [onDone]); + + useEffect(() => { + if (phase !== "boot") { + return; + } + + const cursorTimer = setInterval(() => { + setShowPromptCursor((value) => !value); + }, 140); + + return () => clearInterval(cursorTimer); + }, [phase]); + + useEffect(() => { + if (phase !== "boot") { + return; + } + + let cancelled = false; + let index = 0; + + const pushChunk = () => { + if (cancelled) { + return; + } + + const randomBurst = 2 + Math.floor(Math.random() * 6); + const dynamicBurst = Math.min(randomBurst, lines.length - index); + if (dynamicBurst <= 0) { + setTimeout(() => { + Animated.timing(containerOpacity, { + toValue: 0, + duration: 350, + useNativeDriver: true, + }).start(({ finished }) => { + if (finished) { + finishBoot(); + } + }); + }, 110); + return; + } + + const next = lines.slice(index, index + dynamicBurst); + index += dynamicBurst; + setVisibleLines((prev) => prev.concat(next)); + + const stutter = Math.random() > 0.86; + const delay = stutter + ? 60 + Math.floor(Math.random() * 95) + : 14 + Math.floor(Math.random() * 24); + setTimeout(pushChunk, delay); + }; + + setTimeout(pushChunk, 45); + + return () => { + cancelled = true; + }; + }, [containerOpacity, finishBoot, lines, phase]); + + useEffect(() => { + const hardTimeout = setTimeout(() => { + if (finishRef.current) { + return; + } + + Animated.timing(containerOpacity, { + toValue: 0, + duration: 220, + useNativeDriver: true, + }).start(() => { + finishBoot(); + }); + }, 5000); + + return () => clearTimeout(hardTimeout); + }, [containerOpacity, finishBoot]); + + useEffect(() => { + if (phase !== "boot") { + return; + } + const timer = setTimeout(() => { + scrollRef.current?.scrollToEnd({ animated: true }); + }, 0); + return () => clearTimeout(timer); + }, [phase, visibleLines]); + + return ( + + + {phase === "boot" ? ( + { + scrollRef.current = ref; + }} + style={styles.scroll} + contentContainerStyle={styles.scrollContent} + showsVerticalScrollIndicator={false} + > + {visibleLines.map((line, index) => ( + + ))} + {showPromptCursor ? "_" : " "} + + ) : null} + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: TERM.bg, + zIndex: 50, + }, + stage: { + flex: 1, + }, + scroll: { + flex: 1, + }, + scrollContent: { + paddingHorizontal: 0, + paddingTop: 0, + paddingBottom: 0, + }, + line: { + color: TERM.fg, + fontFamily: "SpaceMono", + fontSize: 12, + lineHeight: 16, + }, + prompt: { + color: TERM.bright, + }, + statusPill: { + backgroundColor: "#101010", + }, +}); diff --git a/frontend/components/Collapsible.tsx b/frontend/components/Collapsible.tsx index 55bff2f..dac87b3 100644 --- a/frontend/components/Collapsible.tsx +++ b/frontend/components/Collapsible.tsx @@ -1,45 +1,77 @@ -import { PropsWithChildren, useState } from 'react'; -import { StyleSheet, TouchableOpacity } from 'react-native'; +import { PropsWithChildren, type ReactNode, useState } from "react"; +import { StyleSheet, TouchableOpacity } from "react-native"; -import { ThemedText } from '@/components/ThemedText'; -import { ThemedView } from '@/components/ThemedView'; -import { IconSymbol } from '@/components/ui/IconSymbol'; -import { Colors } from '@/constants/Colors'; -import { useColorScheme } from '@/hooks/useColorScheme'; +import { ThemedText } from "@/components/ThemedText"; +import { ThemedView } from "@/components/ThemedView"; +import { IconSymbol } from "@/components/ui/IconSymbol"; +import { useAppColors } from "@/hooks/useAppColors"; -export function Collapsible({ children, title }: PropsWithChildren & { title: string }) { - const [isOpen, setIsOpen] = useState(false); - const theme = useColorScheme() ?? 'light'; +type CollapsibleProps = PropsWithChildren & { + title: ReactNode; + defaultOpen?: boolean; +}; + +export function Collapsible({ + children, + title, + defaultOpen = false, +}: CollapsibleProps) { + const [isOpen, setIsOpen] = useState(defaultOpen); + const colors = useAppColors(); + const isTitleText = typeof title === "string" || typeof title === "number"; return ( - + setIsOpen((value) => !value)} - activeOpacity={0.8}> + activeOpacity={0.8} + > - {title} + {isTitleText ? ( + {title} + ) : ( + title + )} - {isOpen && {children}} + {isOpen && ( + + {children} + + )} ); } const styles = StyleSheet.create({ + container: { + borderRadius: 12, + borderWidth: 1, + overflow: "hidden", + }, heading: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", gap: 6, + paddingHorizontal: 12, + paddingVertical: 10, }, content: { - marginTop: 6, - marginLeft: 24, + paddingHorizontal: 12, + paddingVertical: 10, }, }); diff --git a/frontend/components/CreatePost.tsx b/frontend/components/CreatePost.tsx index 39d34d1..634cb4e 100644 --- a/frontend/components/CreatePost.tsx +++ b/frontend/components/CreatePost.tsx @@ -1,15 +1,149 @@ -import { FAB } from "@rneui/themed"; -import { Icon } from "@rneui/themed"; +import React, { useState } from "react"; +import { Animated, Modal, Pressable, StyleSheet, View } from "react-native"; +import { Feather } from "@expo/vector-icons"; +import * as Haptics from "expo-haptics"; +import { useRouter } from "expo-router"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { ThemedText } from "@/components/ThemedText"; +import { useAppColors } from "@/hooks/useAppColors"; export default function CreatePost() { + const colors = useAppColors(); + const insets = useSafeAreaInsets(); + const router = useRouter(); + const [menuOpen, setMenuOpen] = useState(false); + const [slideAnim] = useState(new Animated.Value(24)); + + const openMenu = () => { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + setMenuOpen(true); + slideAnim.setValue(24); + Animated.timing(slideAnim, { + toValue: 0, + duration: 180, + useNativeDriver: true, + }).start(); + }; + + const closeMenu = () => { + setMenuOpen(false); + }; + + const handleAddByte = () => { + closeMenu(); + router.push("/create-byte"); + }; + + const handleAddStream = () => { + closeMenu(); + router.push("/create-stream"); + }; + return ( - } - color="#16ff00" - style={{ marginBottom: 100 }} - size="large" - /> + + [ + styles.button, + { + backgroundColor: colors.tint, + borderColor: colors.border, + bottom: Math.max(14, insets.bottom + 10), + }, + pressed && styles.pressed, + ]} + > + + + + + + + + [ + styles.menuButton, + { borderColor: colors.border }, + pressed && styles.pressed, + ]} + onPress={handleAddStream} + > + + Add stream + + [ + styles.menuButton, + { borderColor: colors.border }, + pressed && styles.pressed, + ]} + onPress={handleAddByte} + > + + Add byte + + + + + ); } + +const styles = StyleSheet.create({ + button: { + position: "absolute", + right: 16, + width: 46, + height: 46, + borderRadius: 14, + alignItems: "center", + justifyContent: "center", + borderWidth: 1, + shadowColor: "#000", + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + backdrop: { + flex: 1, + backgroundColor: "rgba(0, 0, 0, 0.35)", + }, + menu: { + position: "absolute", + right: 16, + borderRadius: 14, + borderWidth: 1, + padding: 12, + gap: 10, + minWidth: 170, + }, + menuButton: { + flexDirection: "row", + alignItems: "center", + gap: 8, + borderRadius: 10, + borderWidth: 1, + paddingVertical: 10, + paddingHorizontal: 12, + }, + pressed: { + opacity: 0.8, + transform: [{ scale: 0.98 }], + }, +}); diff --git a/frontend/components/FadeInImage.tsx b/frontend/components/FadeInImage.tsx new file mode 100644 index 0000000..818b1d5 --- /dev/null +++ b/frontend/components/FadeInImage.tsx @@ -0,0 +1,171 @@ +import React, { useEffect, useRef } from "react"; +import { Animated, Image, ImageProps, View } from "react-native"; +import { usePreferences } from "@/contexts/PreferencesContext"; +import { useMotionConfig } from "@/hooks/useMotionConfig"; + +type FadeInImageProps = ImageProps & { + duration?: number; + onLoadFailed?: () => void; +}; + +const prefetchedImageSources = new Set(); + +export function FadeInImage({ + duration = 180, + onLoad, + onError, + onLoadFailed, + onLoadEnd, + style, + ...props +}: FadeInImageProps) { + const { preferences } = usePreferences(); + const motion = useMotionConfig(); + const shouldAnimate = + preferences.imageRevealEffect === "smooth" && !motion.prefersReducedMotion; + const opacity = useRef(new Animated.Value(shouldAnimate ? 0.06 : 1)).current; + const scale = useRef(new Animated.Value(shouldAnimate ? 1.015 : 1)).current; + const fallbackTimerRef = useRef | null>(null); + const hasRevealedRef = useRef(false); + const [loadFailed, setLoadFailed] = React.useState(false); + const sourceUri = + typeof props.source === "object" && + props.source !== null && + "uri" in props.source + ? (props.source.uri ?? "") + : ""; + + useEffect(() => { + hasRevealedRef.current = false; + setLoadFailed(false); + if (!shouldAnimate) { + opacity.setValue(1); + scale.setValue(1); + hasRevealedRef.current = true; + return; + } + opacity.setValue(0.06); + scale.setValue(1.015); + }, [opacity, scale, shouldAnimate, sourceUri]); + + useEffect(() => { + if (!shouldAnimate) { + opacity.setValue(1); + scale.setValue(1); + hasRevealedRef.current = true; + return; + } + + // Only prefetch http(s) URLs — file:// and content:// URIs are local + // and prefetching them can fail or is pointless. + if ( + sourceUri && + /^https?:\/\//i.test(sourceUri) && + !prefetchedImageSources.has(sourceUri) + ) { + prefetchedImageSources.add(sourceUri); + void Image.prefetch(sourceUri).catch(() => { + prefetchedImageSources.delete(sourceUri); + }); + } + + fallbackTimerRef.current = setTimeout( + () => { + if (!hasRevealedRef.current) { + hasRevealedRef.current = true; + Animated.parallel([ + Animated.timing(opacity, { + toValue: 1, + duration: Math.max(140, duration), + useNativeDriver: true, + }), + Animated.timing(scale, { + toValue: 1, + duration: Math.max(180, duration + 20), + useNativeDriver: true, + }), + ]).start(({ finished }) => { + if (finished) { + opacity.setValue(1); + scale.setValue(1); + } + }); + } + fallbackTimerRef.current = null; + }, + Math.max(90, duration - 40), + ); + + return () => { + if (fallbackTimerRef.current) { + clearTimeout(fallbackTimerRef.current); + fallbackTimerRef.current = null; + } + }; + }, [duration, opacity, scale, shouldAnimate, sourceUri]); + + const reveal = () => { + if (!shouldAnimate) { + return; + } + if (hasRevealedRef.current) { + return; + } + hasRevealedRef.current = true; + if (fallbackTimerRef.current) { + clearTimeout(fallbackTimerRef.current); + fallbackTimerRef.current = null; + } + Animated.timing(opacity, { + toValue: 1, + duration, + useNativeDriver: true, + }).start(({ finished }) => { + if (finished) { + opacity.setValue(1); + } + }); + Animated.timing(scale, { + toValue: 1, + duration: Math.max(180, duration + 20), + useNativeDriver: true, + }).start(({ finished }) => { + if (finished) { + scale.setValue(1); + } + }); + }; + + const handleLoad: ImageProps["onLoad"] = (event) => { + reveal(); + onLoad?.(event); + }; + + const handleError: ImageProps["onError"] = (event) => { + reveal(); + setLoadFailed(true); + onLoadFailed?.(); + onError?.(event); + }; + + const handleLoadEnd: ImageProps["onLoadEnd"] = (event) => { + reveal(); + onLoadEnd?.(event); + }; + + if (loadFailed) { + // Return an empty View so the parent layout stays stable but no broken + // image is rendered (avoids EmptyDrawable / infinite retry warnings). + return ; + } + + return ( + + ); +} diff --git a/frontend/components/FloatingScrollTopButton.tsx b/frontend/components/FloatingScrollTopButton.tsx new file mode 100644 index 0000000..da915aa --- /dev/null +++ b/frontend/components/FloatingScrollTopButton.tsx @@ -0,0 +1,99 @@ +import React, { useEffect, useRef, useState } from "react"; +import { Animated, Pressable, StyleSheet } from "react-native"; +import { Feather } from "@expo/vector-icons"; +import * as Haptics from "expo-haptics"; +import { useAppColors } from "@/hooks/useAppColors"; + +type FloatingScrollTopButtonProps = { + scrollY: Animated.Value; + onPress: () => void; + bottomOffset?: number; +}; + +export function FloatingScrollTopButton({ + scrollY, + onPress, + bottomOffset = 20, +}: FloatingScrollTopButtonProps) { + const colors = useAppColors(); + const [isVisible, setIsVisible] = useState(false); + const visibilityAnim = useRef(new Animated.Value(0)).current; + + useEffect(() => { + const listenerId = scrollY.addListener(({ value }) => { + const nextVisible = value > 360; + setIsVisible((prev) => { + if (prev === nextVisible) { + return prev; + } + Animated.timing(visibilityAnim, { + toValue: nextVisible ? 1 : 0, + duration: nextVisible ? 180 : 140, + useNativeDriver: true, + }).start(); + return nextVisible; + }); + }); + + return () => { + scrollY.removeListener(listenerId); + }; + }, [scrollY, visibilityAnim]); + + const handlePress = () => { + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + onPress(); + }; + + return ( + + [ + styles.button, + { backgroundColor: colors.surface, borderColor: colors.border }, + pressed && styles.pressed, + ]} + > + + + + ); +} + +const styles = StyleSheet.create({ + container: { + position: "absolute", + left: 16, + zIndex: 3, + }, + button: { + width: 42, + height: 42, + borderRadius: 21, + borderWidth: 1, + alignItems: "center", + justifyContent: "center", + }, + pressed: { + opacity: 0.8, + transform: [{ scale: 0.96 }], + }, +}); diff --git a/frontend/components/HyprBackdrop.tsx b/frontend/components/HyprBackdrop.tsx new file mode 100644 index 0000000..c4f3303 --- /dev/null +++ b/frontend/components/HyprBackdrop.tsx @@ -0,0 +1,76 @@ +import React, { useEffect, useRef } from "react"; +import { Animated, StyleSheet, View } from "react-native"; +import { LinearGradient } from "expo-linear-gradient"; +import { useColorScheme } from "@/hooks/useColorScheme"; + +export function HyprBackdrop() { + const scheme = useColorScheme() ?? "light"; + const drift = useRef(new Animated.Value(0)).current; + + useEffect(() => { + Animated.loop( + Animated.sequence([ + Animated.timing(drift, { + toValue: 1, + duration: 12000, + useNativeDriver: true, + }), + Animated.timing(drift, { + toValue: 0, + duration: 12000, + useNativeDriver: true, + }), + ]), + ).start(); + }, [drift]); + + const translateX = drift.interpolate({ + inputRange: [0, 1], + outputRange: [-20, 20], + }); + const translateY = drift.interpolate({ + inputRange: [0, 1], + outputRange: [10, -10], + }); + + const baseColors = + scheme === "dark" ? ["#0a1110", "#0e1715"] : ["#f2f7f3", "#e6f2eb"]; + const glowColors = + scheme === "dark" + ? ["rgba(58, 227, 186, 0.18)", "rgba(51, 184, 255, 0.12)"] + : ["rgba(61, 211, 176, 0.22)", "rgba(77, 176, 255, 0.18)"]; + + return ( + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + ...StyleSheet.absoluteFillObject, + }, + glowLayer: { + ...StyleSheet.absoluteFillObject, + opacity: 0.9, + }, +}); diff --git a/frontend/components/InAppNotificationBanner.tsx b/frontend/components/InAppNotificationBanner.tsx new file mode 100644 index 0000000..54c6a06 --- /dev/null +++ b/frontend/components/InAppNotificationBanner.tsx @@ -0,0 +1,216 @@ +import React, { useEffect, useRef, useState } from "react"; +import { + Animated, + Easing, + PanResponder, + Pressable, + StyleSheet, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Feather } from "@expo/vector-icons"; +import { ThemedText } from "@/components/ThemedText"; +import { useAppColors } from "@/hooks/useAppColors"; + +type InAppNotificationBannerProps = { + visible: boolean; + title: string; + body: string; + onPress: () => void; + onDismiss: () => void; +}; + +export function InAppNotificationBanner({ + visible, + title, + body, + onPress, + onDismiss, +}: InAppNotificationBannerProps) { + const colors = useAppColors(); + const insets = useSafeAreaInsets(); + const translateY = useRef(new Animated.Value(-120)).current; + const opacity = useRef(new Animated.Value(0)).current; + const dragY = useRef(new Animated.Value(0)).current; + const timeoutRef = useRef | null>(null); + const [isRendered, setIsRendered] = useState(visible); + const enterEase = Easing.bezier(0.22, 1, 0.36, 1); + const swipeDismissDistance = -44; + const swipeDismissVelocity = -0.85; + + const panResponder = useRef( + PanResponder.create({ + onMoveShouldSetPanResponder: (_, gestureState) => + Math.abs(gestureState.dy) > Math.abs(gestureState.dx) && + gestureState.dy < -4, + onPanResponderMove: (_, gestureState) => { + dragY.setValue(Math.min(0, gestureState.dy)); + }, + onPanResponderRelease: (_, gestureState) => { + const shouldDismiss = + gestureState.dy <= swipeDismissDistance || + gestureState.vy <= swipeDismissVelocity; + + if (shouldDismiss) { + Animated.timing(dragY, { + toValue: -28, + duration: 90, + easing: enterEase, + useNativeDriver: true, + }).start(() => { + dragY.setValue(0); + onDismiss(); + }); + return; + } + + Animated.timing(dragY, { + toValue: 0, + duration: 160, + easing: enterEase, + useNativeDriver: true, + }).start(); + }, + onPanResponderTerminate: () => { + Animated.timing(dragY, { + toValue: 0, + duration: 160, + easing: enterEase, + useNativeDriver: true, + }).start(); + }, + }), + ).current; + + useEffect(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + + if (!visible) { + dragY.setValue(0); + Animated.parallel([ + Animated.timing(translateY, { + toValue: -120, + duration: 320, + easing: enterEase, + useNativeDriver: true, + }), + Animated.timing(opacity, { + toValue: 0, + duration: 260, + easing: enterEase, + useNativeDriver: true, + }), + ]).start(({ finished }) => { + if (finished) { + setIsRendered(false); + } + }); + return; + } + + setIsRendered(true); + dragY.setValue(0); + + Animated.parallel([ + Animated.timing(translateY, { + toValue: 0, + duration: 320, + easing: enterEase, + useNativeDriver: true, + }), + Animated.timing(opacity, { + toValue: 1, + duration: 260, + easing: enterEase, + useNativeDriver: true, + }), + ]).start(); + + timeoutRef.current = setTimeout(() => { + onDismiss(); + timeoutRef.current = null; + }, 4200); + + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }; + }, [dragY, enterEase, onDismiss, opacity, translateY, visible]); + + if (!isRendered) { + return null; + } + + return ( + + + + + + {title} + + {body} + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + position: "absolute", + left: 12, + right: 12, + zIndex: 2000, + }, + card: { + borderRadius: 12, + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 10, + shadowOpacity: 0.2, + shadowRadius: 10, + shadowOffset: { width: 0, height: 4 }, + elevation: 4, + }, + row: { + flexDirection: "row", + alignItems: "center", + gap: 10, + }, + textWrap: { + flex: 1, + gap: 2, + }, +}); diff --git a/frontend/components/InfiniteHorizontalCycle.tsx b/frontend/components/InfiniteHorizontalCycle.tsx new file mode 100644 index 0000000..04ccfab --- /dev/null +++ b/frontend/components/InfiniteHorizontalCycle.tsx @@ -0,0 +1,137 @@ +import React, { useEffect, useMemo, useRef } from "react"; +import { FlatList, StyleSheet, View } from "react-native"; + +type InfiniteHorizontalCycleProps = { + data: T[]; + itemWidth: number; + itemGap?: number; + sidePadding?: number; + repeat?: boolean; + minRepeatCount?: number; + keyExtractor: (item: T, index: number) => string; + renderItem: (item: T, index: number) => React.ReactElement; + showsHorizontalScrollIndicator?: boolean; +}; + +export function InfiniteHorizontalCycle({ + data, + itemWidth, + itemGap = 12, + sidePadding = 16, + repeat = true, + minRepeatCount = 2, + keyExtractor, + renderItem, + showsHorizontalScrollIndicator = false, +}: InfiniteHorizontalCycleProps) { + const listRef = useRef>(null); + const itemSpan = itemWidth + itemGap; + const shouldRepeat = repeat && data.length >= minRepeatCount; + + const cycleData = useMemo(() => { + if (data.length === 0) { + return []; + } + if (!shouldRepeat) { + return data; + } + return [...data, ...data, ...data]; + }, [data, shouldRepeat]); + + const centerOffset = useMemo(() => { + if (!shouldRepeat || data.length === 0) { + return 0; + } + return itemSpan * data.length; + }, [data.length, itemSpan, shouldRepeat]); + + useEffect(() => { + if (!shouldRepeat || data.length === 0) { + return; + } + const task = setTimeout(() => { + listRef.current?.scrollToOffset({ + offset: centerOffset, + animated: false, + }); + }, 0); + + return () => clearTimeout(task); + }, [centerOffset, data.length, shouldRepeat]); + + const recenterIfNeeded = (offsetX: number) => { + if (!shouldRepeat || data.length === 0) { + return; + } + const totalSpan = itemSpan * data.length; + const minSafe = totalSpan * 0.5; + const maxSafe = totalSpan * 1.5; + + if (offsetX < minSafe) { + listRef.current?.scrollToOffset({ + offset: offsetX + totalSpan, + animated: false, + }); + return; + } + + if (offsetX > maxSafe) { + listRef.current?.scrollToOffset({ + offset: offsetX - totalSpan, + animated: false, + }); + } + }; + + return ( + { + const baseIndex = data.length > 0 ? index % data.length : index; + return `${keyExtractor(item, baseIndex)}-${index}`; + }} + renderItem={({ item, index }) => { + const baseIndex = data.length > 0 ? index % data.length : index; + return ( + + {renderItem(item, baseIndex)} + + ); + }} + showsHorizontalScrollIndicator={showsHorizontalScrollIndicator} + contentContainerStyle={{ + paddingLeft: sidePadding, + paddingRight: Math.max(0, sidePadding - itemGap), + alignItems: "flex-start", + }} + onMomentumScrollEnd={(event) => { + recenterIfNeeded(event.nativeEvent.contentOffset.x); + }} + onScrollEndDrag={(event) => { + recenterIfNeeded(event.nativeEvent.contentOffset.x); + }} + scrollEventThrottle={16} + bounces + initialNumToRender={6} + maxToRenderPerBatch={8} + windowSize={7} + removeClippedSubviews + getItemLayout={(_, index) => ({ + length: itemSpan, + offset: itemSpan * index, + index, + })} + /> + ); +} + +const styles = StyleSheet.create({ + itemWrap: { + justifyContent: "flex-start", + alignItems: "flex-start", + }, +}); diff --git a/frontend/components/LazyFadeIn.tsx b/frontend/components/LazyFadeIn.tsx new file mode 100644 index 0000000..763dbe8 --- /dev/null +++ b/frontend/components/LazyFadeIn.tsx @@ -0,0 +1,118 @@ +import React, { useEffect, useRef, useState } from "react"; +import { Animated, Easing, StyleProp, ViewStyle } from "react-native"; +import { usePreferences } from "@/contexts/PreferencesContext"; +import { useMotionConfig } from "@/hooks/useMotionConfig"; + +type LazyFadeInProps = { + visible: boolean; + duration?: number; + style?: StyleProp; + children?: React.ReactNode; +}; + +export function LazyFadeIn({ + visible, + duration = 220, + style, + children, +}: LazyFadeInProps) { + const { preferences } = usePreferences(); + const motion = useMotionConfig(); + const shouldAnimate = + preferences.imageRevealEffect === "smooth" && !motion.prefersReducedMotion; + const opacity = useRef(new Animated.Value(0)).current; + const translateY = useRef(new Animated.Value(6)).current; + const scale = useRef(new Animated.Value(0.996)).current; + const hasAnimatedRef = useRef(false); + const [hasMountedVisible, setHasMountedVisible] = useState(false); + + useEffect(() => { + if (!visible) { + hasAnimatedRef.current = false; + setHasMountedVisible(false); + opacity.setValue(0); + translateY.setValue(6); + scale.setValue(0.996); + return; + } + + if (!hasMountedVisible) { + setHasMountedVisible(true); + } + + if (!shouldAnimate) { + hasAnimatedRef.current = true; + opacity.setValue(1); + translateY.setValue(0); + scale.setValue(1); + return; + } + + if (hasAnimatedRef.current) { + opacity.setValue(1); + translateY.setValue(0); + scale.setValue(1); + return; + } + + Animated.parallel([ + Animated.timing(opacity, { + toValue: 1, + duration, + easing: Easing.bezier(0.22, 1, 0.36, 1), + useNativeDriver: true, + }), + Animated.timing(translateY, { + toValue: 0, + duration, + easing: Easing.bezier(0.22, 1, 0.36, 1), + useNativeDriver: true, + }), + Animated.timing(scale, { + toValue: 1, + duration, + easing: Easing.bezier(0.22, 1, 0.36, 1), + useNativeDriver: true, + }), + ]).start(({ finished }) => { + if (!finished) { + return; + } + hasAnimatedRef.current = true; + opacity.setValue(1); + translateY.setValue(0); + scale.setValue(1); + }); + }, [ + duration, + hasMountedVisible, + opacity, + scale, + shouldAnimate, + translateY, + visible, + ]); + + if (!visible) { + return ( + + ); + } + + return ( + + {children} + + ); +} diff --git a/frontend/components/MarkdownText.tsx b/frontend/components/MarkdownText.tsx new file mode 100644 index 0000000..55d76b0 --- /dev/null +++ b/frontend/components/MarkdownText.tsx @@ -0,0 +1,1362 @@ +import React, { useEffect, useMemo, useState } from "react"; +import Markdown from "react-native-markdown-display"; +import * as Linking from "expo-linking"; +import { + ActivityIndicator, + Alert, + Animated, + Pressable, + StyleSheet, + Text, + useWindowDimensions, + View, +} from "react-native"; +import { WebView } from "react-native-webview"; +import { Collapsible } from "@/components/Collapsible"; +import { FadeInImage } from "@/components/FadeInImage"; +import { useAppColors } from "@/hooks/useAppColors"; +import { usePreferences } from "@/contexts/PreferencesContext"; +import { useMotionConfig } from "@/hooks/useMotionConfig"; +import { API_BASE_URL, resolveMediaUrl } from "@/services/api"; + +type MarkdownTextProps = { + children: string; + compact?: boolean; + preferStatic?: boolean; +}; + +const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "0.0.0.0"]); + +const stripMarkdownImageTitle = (value: string) => { + const match = value.match(/^(.+?)\s+(?:"[^"]*"|'[^']*')\s*$/); + return match ? match[1].trim() : value; +}; + +const sanitizeImageInput = (value: string) => + stripMarkdownImageTitle( + value + .trim() + .replace(/^<+|>+$/g, "") + .replace(/^['"]+|['"]+$/g, "") + .replace(/\\([()])/g, "$1") + .replace(/&/g, "&") + .trim(), + ); + +const rewriteLocalhostToApiBase = (url: URL) => { + const host = url.hostname.toLowerCase(); + if (!LOCAL_HOSTS.has(host)) { + return url; + } + + try { + const apiUrl = new URL(API_BASE_URL); + const next = new URL(url.toString()); + next.protocol = apiUrl.protocol; + next.hostname = apiUrl.hostname; + next.port = apiUrl.port; + return next; + } catch { + return url; + } +}; + +const normalizeImageSourceUrl = (value: string) => { + const source = sanitizeImageInput(value); + if (!source) { + return ""; + } + + if (/^data:image\//i.test(source)) { + return source; + } + + if (/^\/?uploads\//i.test(source)) { + return resolveMediaUrl(source.startsWith("/") ? source : `/${source}`); + } + + if (source.startsWith("./") || source.startsWith("../")) { + return resolveMediaUrl(source.replace(/^\.?\//, "/")); + } + + if (source.startsWith("/")) { + try { + // Convert absolute-path references to full API URLs so fetch() and Image URI + // consumers have a valid absolute URL (avoids "Invalid URL: /path" errors). + return `${API_BASE_URL}${source}`; + } catch { + return ""; + } + } + + const candidate = source.startsWith("//") + ? `https:${source}` + : source.startsWith("www.") + ? `https://${source}` + : source; + + if (!/^[a-z][a-z0-9+.-]*:/i.test(candidate)) { + return resolveMediaUrl(`/${candidate.replace(/^\/+/, "")}`); + } + + try { + const parsed = rewriteLocalhostToApiBase(new URL(candidate)); + if (!/^https?:$/i.test(parsed.protocol)) { + return ""; + } + + if (parsed.hostname !== "github.com") { + return parsed.toString(); + } + + const segments = parsed.pathname.split("/").filter(Boolean); + const blobIndex = segments.indexOf("blob"); + const rawIndex = segments.indexOf("raw"); + + if (segments.length >= 5 && blobIndex === 2) { + const [owner, repo, , branch, ...asset] = segments; + if (owner && repo && branch && asset.length > 0) { + return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${asset.join("/")}`; + } + } + + if (segments.length >= 5 && rawIndex === 2) { + const [owner, repo, , branch, ...asset] = segments; + if (owner && repo && branch && asset.length > 0) { + return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${asset.join("/")}`; + } + } + + return parsed.toString(); + } catch { + return ""; + } +}; + +const isSvgUrl = (value: string) => { + const clean = value.split("?")[0].split("#")[0].toLowerCase(); + return clean.endsWith(".svg"); +}; + +const parseSvgAspectRatio = (svg: string) => { + const viewBoxMatch = svg.match( + /viewBox\s*=\s*["']\s*([\d.+-]+)[\s,]+([\d.+-]+)[\s,]+([\d.+-]+)[\s,]+([\d.+-]+)\s*["']/i, + ); + if (viewBoxMatch) { + const width = Number(viewBoxMatch[3]); + const height = Number(viewBoxMatch[4]); + if ( + width > 0 && + height > 0 && + Number.isFinite(width) && + Number.isFinite(height) + ) { + return width / height; + } + } + + const widthMatch = svg.match( + /\bwidth\s*=\s*["']\s*([\d.+-]+)(?:px)?\s*["']/i, + ); + const heightMatch = svg.match( + /\bheight\s*=\s*["']\s*([\d.+-]+)(?:px)?\s*["']/i, + ); + const width = widthMatch ? Number(widthMatch[1]) : NaN; + const height = heightMatch ? Number(heightMatch[1]) : NaN; + if ( + width > 0 && + height > 0 && + Number.isFinite(width) && + Number.isFinite(height) + ) { + return width / height; + } + + return 16 / 9; +}; + +type InlineCodeScope = "callout" | "blockquote" | null; + +const InlineCodeContext = React.createContext<{ scope: InlineCodeScope }>({ + scope: null, +}); + +const imageAspectRatioCache = new Map(); + +function AnimatedMarkdownBlock({ + children, + index, + enabled, + mode, +}: { + children: React.ReactNode; + index: number; + enabled: boolean; + mode: "smooth" | "typewriter" | "wave" | "random" | "off"; +}) { + const opacity = React.useRef(new Animated.Value(enabled ? 0 : 1)).current; + const translateY = React.useRef(new Animated.Value(enabled ? 10 : 0)).current; + const scale = React.useRef(new Animated.Value(enabled ? 0.996 : 1)).current; + const hasAnimatedRef = React.useRef(false); + + React.useEffect(() => { + if (!enabled) { + hasAnimatedRef.current = true; + opacity.setValue(1); + translateY.setValue(0); + scale.setValue(1); + return; + } + + if (hasAnimatedRef.current) { + return; + } + + const staggerSeed = mode === "random" ? (index * 37) % 5 : index; + const delay = Math.min(260, staggerSeed * 34); + const duration = mode === "typewriter" ? 300 : mode === "wave" ? 260 : 220; + + opacity.setValue(0.04); + translateY.setValue(10); + scale.setValue(0.996); + + Animated.parallel([ + Animated.timing(opacity, { + toValue: 1, + duration, + delay, + useNativeDriver: true, + }), + Animated.timing(translateY, { + toValue: 0, + duration: duration + 30, + delay, + useNativeDriver: true, + }), + Animated.timing(scale, { + toValue: 1, + duration, + delay, + useNativeDriver: true, + }), + ]).start(({ finished }) => { + if (!finished) { + return; + } + hasAnimatedRef.current = true; + opacity.setValue(1); + translateY.setValue(0); + scale.setValue(1); + }); + }, [enabled, index, mode, opacity, scale, translateY]); + + return ( + + {children} + + ); +} + +export function MarkdownText({ + children, + compact = false, + preferStatic = false, +}: MarkdownTextProps) { + const colors = useAppColors(); + const { preferences } = usePreferences(); + const motion = useMotionConfig(); + const { width: windowWidth } = useWindowDimensions(); + const [imageAspectRatios] = useState>({}); + const isLargeDocument = children.length > 1400; + const useStaticRendering = preferStatic || isLargeDocument; + const toRgba = (hex: string, alpha: number) => { + const normalized = hex.replace("#", ""); + const value = + normalized.length === 3 + ? normalized + .split("") + .map((channel) => channel + channel) + .join("") + : normalized; + if (value.length !== 6) return hex; + const red = parseInt(value.slice(0, 2), 16); + const green = parseInt(value.slice(2, 4), 16); + const blue = parseInt(value.slice(4, 6), 16); + return `rgba(${red}, ${green}, ${blue}, ${alpha})`; + }; + const linkifyText = (text: string) => + text.replace( + /(^|\s)((?:https?:\/\/|www\.)[^\s<]+[^\s<\.)])/g, + (_match, prefix, url) => `${prefix}[${url}](${url})`, + ); + const sanitizeGithubCallouts = (text: string) => + text.replace( + /^>\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*>\s*/gim, + "> [!$1] ", + ); + const applyTaskCheckboxes = (text: string) => + text.replace( + /(^|\n)(\s*)[-*]\s+\[( |x|X)\]\s+/g, + (_match, lineStart, indent, mark) => + `${lineStart}${indent.replace(/\s/g, "\u00A0")}${ + mark.trim().toLowerCase() === "x" ? "☑" : "☐" + }\u00A0`, + ); + const normalizeListTransitions = (text: string) => { + const lines = text.split(/\r?\n/); + const result: string[] = []; + let prevType: "ordered" | "unordered" | null = null; + let prevIndent = ""; + + const getListInfo = (line: string) => { + const match = line.match(/^((?:>\s*)*)(\s*)(\d+\.|[-*+])\s+/); + if (!match) return null; + const marker = match[3]; + return { + indent: `${match[1]}${match[2]}`, + type: /\d+\./.test(marker) ? "ordered" : "unordered", + } as const; + }; + + lines.forEach((line) => { + const info = getListInfo(line); + if ( + info && + prevType && + info.indent === prevIndent && + info.type !== prevType && + result.length > 0 && + result[result.length - 1].trim() !== "" + ) { + result.push(""); + } + result.push(line); + if (info) { + prevType = info.type; + prevIndent = info.indent; + } else if (line.trim() === "") { + prevType = null; + prevIndent = ""; + } + }); + + return result.join("\n"); + }; + const preprocessMarkdown = (text: string) => + linkifyText( + normalizeListTransitions( + applyTaskCheckboxes(sanitizeGithubCallouts(text)), + ), + ); + + const normalizeDetailsTags = (text: string) => + text + .replace(/&lt;(\/?details[^&]*)&gt;/gi, "<$1>") + .replace(/&lt;(\/?summary[^&]*)&gt;/gi, "<$1>") + .replace(/<(\/?details[^&]*)>/gi, "<$1>") + .replace(/<(\/?summary[^&]*)>/gi, "<$1>") + .replace(/\\<(\/?details[^>]*)>/gi, "<$1>") + .replace(/\\<(\/?summary[^>]*)>/gi, "<$1>"); + + const parseDetailsBlocks = (text: string) => { + const blocks: ( + | { type: "markdown"; content: string } + | { type: "details"; summary: string; content: string; open: boolean } + )[] = []; + const tagRegex = /<\/?details[^>]*>/gi; + let cursor = 0; + let depth = 0; + let openContentStart = -1; + let openTagIndex = -1; + let openByDefault = false; + let match: RegExpExecArray | null = tagRegex.exec(text); + + while (match) { + const tag = match[0]; + const isClose = tag.startsWith(" cursor) { + blocks.push({ + type: "markdown", + content: text.slice(cursor, match.index), + }); + } + openTagIndex = match.index; + openContentStart = match.index + tag.length; + openByDefault = /]*\bopen\b/i.test(tag); + } + depth += 1; + } else { + if (depth > 0) { + depth -= 1; + if (depth === 0 && openContentStart !== -1) { + const inner = text.slice(openContentStart, match.index); + const summaryMatch = inner.match( + /]*>([\s\S]*?)<\/summary>/i, + ); + const summaryRaw = summaryMatch?.[1]?.trim() ?? "Details"; + const content = summaryMatch + ? inner.replace(summaryMatch[0], "").trim() + : inner.trim(); + blocks.push({ + type: "details", + summary: summaryRaw, + content, + open: openByDefault, + }); + cursor = match.index + tag.length; + openContentStart = -1; + openTagIndex = -1; + openByDefault = false; + } + } + } + match = tagRegex.exec(text); + } + + if (depth > 0 && openTagIndex !== -1) { + blocks.push({ type: "markdown", content: text.slice(openTagIndex) }); + return blocks; + } + + if (cursor < text.length) { + blocks.push({ type: "markdown", content: text.slice(cursor) }); + } + + return blocks; + }; + const openUrlSafe = async (url: string) => { + const trimmed = url.trim(); + if (!/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) { + if (preferences.linkOpenMode === "promptScheme") { + Alert.alert("Open link", `"${trimmed}" is missing a scheme.`, [ + { text: "Cancel", style: "cancel" }, + { + text: "Add http://", + onPress: () => void openUrlSafe(`http://${trimmed}`), + }, + { + text: "Add https://", + onPress: () => void openUrlSafe(`https://${trimmed}`), + }, + ]); + return; + } + const normalizedHost = trimmed.startsWith("www.") + ? trimmed + : `www.${trimmed}`; + await openUrlSafe(`https://${normalizedHost}`); + return; + } + const supported = await Linking.canOpenURL(trimmed); + if (!supported) { + Alert.alert("Unable to open link", trimmed); + return; + } + await Linking.openURL(trimmed); + }; + + const getCodeContent = (node: { content?: string; children?: any[] }) => { + if (typeof node.content === "string") { + return node.content; + } + if (Array.isArray(node.children)) { + return node.children.map((child) => child.content ?? "").join(""); + } + return ""; + }; + + // Inline code pill styles are self-contained to avoid inherited markdown styles. + const inlineCodeStyles = useMemo(() => { + const background = toRgba(colors.text, 0.08); + const border = toRgba(colors.text, 0.18); + return StyleSheet.create({ + pill: { + backgroundColor: background, + borderColor: border, + borderWidth: 0.7, + borderRadius: 6, + paddingHorizontal: 1, + paddingVertical: 1, + alignSelf: "flex-start", + justifyContent: "center", + }, + text: { + color: colors.tint, + fontFamily: "SpaceMono", + fontSize: 12, + lineHeight: 15, + includeFontPadding: false, + }, + }); + }, [colors.text, colors.tint]); + + const renderCodeBlock = (node: { + key?: string; + content?: string; + children?: any[]; + }) => { + const content = getCodeContent(node); + return ( + + + {content} + + + ); + }; + + const renderImage = (node: { key?: string; attributes?: any }) => { + const originalSrc = String(node.attributes?.src ?? ""); + let src = normalizeImageSourceUrl(originalSrc); + if (!src) { + src = normalizeImageSourceUrl(resolveMediaUrl(originalSrc)); + } + if (!src) { + return null; + } + const maxWidth = Math.min(windowWidth - 32, 520); + return ( + + ); + }; + + const renderLink = ( + node: { key?: string; attributes?: any; children?: any[] }, + children: React.ReactNode[] = [], + ) => { + const href = node.attributes?.href ?? ""; + const hasImageChild = Array.isArray(node.children) + ? node.children.some((child) => child?.type === "image") + : false; + + if (hasImageChild) { + return ( + void openUrlSafe(href)} + style={styles.imageLink} + > + {children} + + ); + } + + return ( + void openUrlSafe(href)} + > + {children} + + ); + }; + + const extractText = (value: React.ReactNode): string => { + if (typeof value === "string" || typeof value === "number") { + return String(value); + } + if (Array.isArray(value)) { + return value.map(extractText).join(""); + } + if (React.isValidElement(value)) { + return extractText((value.props as any)?.children); + } + return ""; + }; + + const stripPrefix = ( + nodes: React.ReactNode, + prefix: string, + ): React.ReactNode[] => { + let remaining = prefix; + const stripNode = (node: React.ReactNode): React.ReactNode | null => { + if (!remaining) return node; + if (typeof node === "string" || typeof node === "number") { + const text = String(node); + if (text.startsWith(remaining)) { + const updated = text.slice(remaining.length); + remaining = ""; + return updated; + } + if (remaining.startsWith(text)) { + remaining = remaining.slice(text.length); + return null; + } + return node; + } + if (React.isValidElement(node)) { + const childNodes = React.Children.toArray( + (node.props as any)?.children, + ); + const updatedChildren = childNodes + .map(stripNode) + .filter((child) => child !== null); + return React.cloneElement(node, node.props as any, updatedChildren); + } + return node; + }; + + return React.Children.toArray(nodes) + .map(stripNode) + .filter((child) => child !== null); + }; + + const renderBlockquote = ( + node: { key?: string }, + children: React.ReactNode, + ) => { + const text = extractText(children); + const match = text.match( + /^\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*/i, + ); + const type = match?.[1]?.toLowerCase() as + | "note" + | "tip" + | "important" + | "warning" + | "caution" + | undefined; + if (!type) { + return ( + + + {children} + + + ); + } + + const palette = calloutPalette[type]; + const cleanedChildren = stripPrefix(children, match?.[0] ?? ""); + + return ( + + + {type.charAt(0).toUpperCase() + type.slice(1)} + + + + {cleanedChildren} + + + + ); + }; + + const inlineCodeOffsets: Record = { + body: 8, + paragraph: 1, + heading1: 7, + heading2: 8, + heading3: 8, + heading4: 7, + heading5: 9, + heading6: 10, + blockquote: 7.5, + callout: 7.5, + }; + const tableTypes = new Set(["table", "thead", "tbody", "tr", "th", "td"]); + const blockquoteTypes = new Set(["blockquote"]); + const headerTypes = new Set([ + "heading1", + "heading2", + "heading3", + "heading4", + "heading5", + "heading6", + ]); + + const InlineCodePill = ({ + content, + parentTypes, + parentType, + nodeParentType, + nodeGrandParentType, + }: { + content: string; + parentTypes: { type?: string }[]; + parentType: string; + nodeParentType?: string; + nodeGrandParentType?: string; + }) => { + const { scope } = React.useContext(InlineCodeContext); + const headerType = + parentTypes.find((item) => headerTypes.has(item.type ?? ""))?.type ?? + (nodeParentType && headerTypes.has(nodeParentType) + ? nodeParentType + : undefined) ?? + (nodeGrandParentType && headerTypes.has(nodeGrandParentType) + ? nodeGrandParentType + : undefined); + const isInTable = + parentTypes.some((item) => tableTypes.has(item.type ?? "")) || + tableTypes.has(parentType) || + (nodeParentType ? tableTypes.has(nodeParentType) : false) || + (nodeGrandParentType ? tableTypes.has(nodeGrandParentType) : false); + const isInBlockquote = + parentTypes.some((item) => blockquoteTypes.has(item.type ?? "")) || + blockquoteTypes.has(parentType) || + (nodeParentType ? blockquoteTypes.has(nodeParentType) : false) || + (nodeGrandParentType ? blockquoteTypes.has(nodeGrandParentType) : false); + const scopedOffset = + scope === "callout" + ? inlineCodeOffsets.callout + : scope === "blockquote" + ? inlineCodeOffsets.blockquote + : null; + const translateY = isInTable + ? 0 + : scopedOffset !== null + ? scopedOffset + : headerType + ? (inlineCodeOffsets[headerType] ?? inlineCodeOffsets.body) + : isInBlockquote + ? inlineCodeOffsets.blockquote + : (inlineCodeOffsets[parentType] ?? inlineCodeOffsets.body); + const translateStyle = translateY ? { transform: [{ translateY }] } : null; + return ( + + {`\u00A0${content}\u00A0`} + + ); + }; + + const renderInlineCode = ( + node: { key?: string; content?: string }, + _children: React.ReactNode[] = [], + parent?: { type?: string } | { type?: string }[], + ) => { + const content = node.content ?? getCodeContent(node); + const parentTypes = Array.isArray(parent) ? parent : parent ? [parent] : []; + const parentType = parentTypes[0]?.type ?? "body"; + const nodeParentType = (node as any)?.parent?.type as string | undefined; + const nodeGrandParentType = (node as any)?.parent?.parent?.type as + | string + | undefined; + return ( + + ); + }; + + const renderText = ( + node: { key?: string; content?: string }, + _children: React.ReactNode[] = [], + parent?: { type?: string } | { type?: string }[], + styles?: any, + inheritedStyles: any = {}, + ) => { + const content = node.content ?? ""; + const hasCheckbox = /[☑☐]/.test(content); + + if (!hasCheckbox || !styles) { + return ( + + {content} + + ); + } + const parts = content.split(/([☑☐])/g); + + return ( + + {parts.map((part, index) => { + if (part === "☑" || part === "☐") { + const markerColor = part === "☑" ? colors.tint : colors.muted; + return ( + + {part} + + ); + } + return part; + })} + + ); + }; + + const renderStrikethrough = ( + node: { key?: string }, + children: React.ReactNode[] = [], + _parent?: { type?: string } | { type?: string }[], + styles?: any, + inheritedStyles: any = {}, + ) => ( + + {children} + + ); + + const renderListItem = ( + node: { key?: string; index?: number; markup?: string }, + children: React.ReactNode[] = [], + parent?: + | { type?: string; attributes?: any } + | { type?: string; attributes?: any }[], + mdStyles?: any, + inheritedStyles: any = {}, + ) => { + const parents = Array.isArray(parent) ? parent : parent ? [parent] : []; + const isBullet = parents.some((item) => item.type === "bullet_list"); + const isOrdered = parents.some((item) => item.type === "ordered_list"); + + if (isBullet && mdStyles) { + return ( + + + + {children} + + + ); + } + + if (isOrdered && mdStyles) { + const orderedListIndex = parents.findIndex( + (item) => item.type === "ordered_list", + ); + const orderedList = parents[orderedListIndex] as + | { attributes?: { start?: number } } + | undefined; + const listItemNumber = orderedList?.attributes?.start + ? orderedList.attributes.start + (node.index ?? 0) + : (node.index ?? 0) + 1; + return ( + + + {listItemNumber} + {node.markup} + + + {children} + + + ); + } + + return ( + + {children} + + ); + }; + + const markdownRules = { + code_inline: renderInlineCode, + text: renderText, + s: renderStrikethrough, + list_item: renderListItem, + code_block: renderCodeBlock, + fence: renderCodeBlock, + image: renderImage, + blockquote: renderBlockquote, + link: renderLink, + }; + + const bodyFontSize = compact ? 13 : 14; + const bodyLineHeight = compact ? 18 : 20; + const markdownStyle = { + body: { + color: colors.text, + fontSize: bodyFontSize, + lineHeight: bodyLineHeight, + }, + heading1: { + color: colors.text, + fontSize: compact ? 18 : 20, + marginBottom: 6, + }, + heading2: { + color: colors.text, + fontSize: compact ? 16 : 18, + marginBottom: 6, + }, + heading3: { + color: colors.text, + fontSize: compact ? 15 : 16, + marginBottom: 6, + }, + hr: { + borderBottomColor: colors.border, + borderBottomWidth: 1, + marginVertical: 16, + }, + bullet_list: { paddingLeft: 12 }, + ordered_list: { paddingLeft: 12 }, + list_item: { marginBottom: 4 }, + task_list_item: { marginBottom: 4 }, + bullet_list_icon: { color: colors.tint }, + ordered_list_icon: { color: colors.tint }, + checkbox: { + borderColor: colors.border, + backgroundColor: colors.surface, + }, + table: { + borderColor: colors.border, + borderWidth: 1, + borderRadius: 8, + overflow: "hidden", + }, + th: { + backgroundColor: colors.surfaceAlt, + borderColor: colors.border, + borderWidth: 1, + paddingHorizontal: 8, + paddingVertical: 6, + }, + td: { + borderColor: colors.border, + borderWidth: 1, + paddingHorizontal: 8, + paddingVertical: 6, + }, + link: { color: colors.tint }, + paragraph: { marginTop: 1, marginBottom: 0 }, + image: { + marginTop: 0, + marginBottom: 0, + alignSelf: "flex-start", + }, + em: { fontStyle: "italic" }, + strong: { fontWeight: "700" }, + s: { + textDecorationLine: "line-through", + textDecorationStyle: "solid", + textDecorationColor: colors.text, + }, + } as const; + + const summaryMarkdownStyle = { + body: { color: colors.text, fontSize: 15, lineHeight: 20 }, + paragraph: { marginTop: 0, marginBottom: 0 }, + link: { color: colors.tint }, + em: { fontStyle: "italic" }, + strong: { fontWeight: "700" }, + s: { + textDecorationLine: "line-through", + textDecorationStyle: "solid", + textDecorationColor: colors.text, + }, + } as const; + + const shouldAnimateMarkdown = + !useStaticRendering && + !motion.prefersReducedMotion && + preferences.textRenderEffect !== "off"; + const enableBlockAnimation = shouldAnimateMarkdown; + + const renderMarkdownSegments = ( + text: string, + keyPrefix: string, + depth = 0, + ) => { + const normalized = normalizeDetailsTags(text); + return parseDetailsBlocks(normalized).map((block, index) => { + const keyBase = `${keyPrefix}-${index}`; + const animationIndex = depth * 10 + index; + if (block.type === "details") { + const summaryText = block.summary.replace(/\s+/g, " ").trim(); + return ( + + { + void openUrlSafe(url); + return false; + }} + rules={markdownRules} + style={summaryMarkdownStyle} + > + {preprocessMarkdown(summaryText)} + + } + defaultOpen={block.open} + > + + {renderMarkdownSegments( + block.content, + `nested-${keyBase}`, + depth + 1, + )} + + + + ); + } + return ( + + { + void openUrlSafe(url); + return false; + }} + rules={markdownRules} + style={markdownStyle} + > + {preprocessMarkdown(block.content)} + + + ); + }); + }; + + const markdownNodes = renderMarkdownSegments(children, "root"); + + return {markdownNodes}; +} + +type MarkdownImageProps = { + src: string; + maxWidth: number; + initialAspectRatio?: number; +}; + +const calloutPalette = { + note: { + border: "#3B82F6", + background: "rgba(59, 130, 246, 0.12)", + title: "#3B82F6", + }, + tip: { + border: "#22C55E", + background: "rgba(34, 197, 94, 0.12)", + title: "#22C55E", + }, + important: { + border: "#EF4444", + background: "rgba(239, 68, 68, 0.12)", + title: "#EF4444", + }, + warning: { + border: "#F59E0B", + background: "rgba(245, 158, 11, 0.12)", + title: "#F59E0B", + }, + caution: { + border: "#FF3B30", + background: "rgba(255, 59, 48, 0.16)", + title: "#FF3B30", + glow: true, + }, +} as const; + +function MarkdownImage({ + src, + maxWidth, + initialAspectRatio, +}: MarkdownImageProps) { + const colors = useAppColors(); + const [aspectRatio, setAspectRatio] = useState( + initialAspectRatio ?? 16 / 9, + ); + const [svgMarkup, setSvgMarkup] = useState(null); + const [isSvgLoaded, setIsSvgLoaded] = useState(false); + const [isRasterLoaded, setIsRasterLoaded] = useState(false); + const isSvg = useMemo(() => isSvgUrl(src), [src]); + + useEffect(() => { + if (isSvg) { + setAspectRatio(16 / 9); + setIsRasterLoaded(false); + return; + } + setAspectRatio(initialAspectRatio ?? 16 / 9); + setIsRasterLoaded(false); + }, [initialAspectRatio, isSvg]); + + useEffect(() => { + if (!isSvg) { + setSvgMarkup(null); + return; + } + + let active = true; + setSvgMarkup(null); + setIsSvgLoaded(false); + + (async () => { + try { + const response = await fetch(src); + if (!active) return; + if (!response.ok) { + throw new Error("Failed to load SVG"); + } + const text = await response.text(); + if (!active) return; + setAspectRatio(parseSvgAspectRatio(text)); + setSvgMarkup(text); + } catch { + if (active) { + setSvgMarkup(null); + } + } + })(); + + return () => { + active = false; + }; + }, [isSvg, src]); + + const svgHtml = useMemo(() => { + const content = svgMarkup + ? svgMarkup + : ``; + return `${content}`; + }, [src, svgMarkup]); + + return isSvg ? ( + + setIsSvgLoaded(true)} + /> + {!isSvgLoaded ? ( + + + + ) : null} + + ) : ( + + { + const width = event.nativeEvent?.source?.width ?? 0; + const height = event.nativeEvent?.source?.height ?? 0; + if (width > 0 && height > 0) { + const ratio = width / height; + imageAspectRatioCache.set(src, ratio); + setAspectRatio(ratio); + } + }} + onLoadEnd={() => { + setIsRasterLoaded(true); + }} + style={styles.rasterImage} + /> + {!isRasterLoaded ? ( + + + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + markdownStack: { + gap: 10, + }, + codeBlock: { + borderRadius: 12, + borderWidth: 1, + padding: 0, + marginVertical: 10, + gap: 8, + }, + blockquoteBase: { + borderLeftWidth: 1, + borderWidth: 1, + paddingLeft: 12, + paddingRight: 12, + paddingVertical: 8, + marginVertical: 8, + borderRadius: 10, + }, + calloutBase: { + gap: 4, + }, + calloutGlow: { + shadowOpacity: 0.35, + shadowRadius: 8, + shadowOffset: { width: 0, height: 3 }, + elevation: 4, + }, + calloutTitle: { + fontSize: 12, + letterSpacing: 0.4, + textTransform: "uppercase", + fontFamily: "SpaceMono", + }, + calloutContent: { + marginTop: 2, + }, + codeText: { + fontFamily: "SpaceMono", + fontSize: 13, + lineHeight: 18, + borderRadius: 10, + borderWidth: 0, + paddingTop: 20, + paddingHorizontal: 20, + paddingBottom: 1, + paddingVertical: 0, + }, + inlineCodePill: { + borderRadius: 6, + borderWidth: 1, + paddingHorizontal: 1, + paddingVertical: 1, + alignSelf: "flex-start", + }, + inlineCodeText: { + fontFamily: "SpaceMono", + fontSize: 12, + lineHeight: 15, + includeFontPadding: false, + }, + imageLink: { + alignSelf: "flex-start", + }, + link: { + textDecorationLine: "underline", + }, + image: { + borderRadius: 10, + }, + svgImage: { + backgroundColor: "transparent", + }, + svgWebView: { + width: "100%", + height: "100%", + backgroundColor: "transparent", + }, + svgImageContainer: { + borderRadius: 10, + overflow: "hidden", + }, + rasterImageContainer: { + borderWidth: 1, + overflow: "hidden", + }, + rasterImage: { + width: "100%", + height: "100%", + }, + svgLoadingOverlay: { + ...StyleSheet.absoluteFillObject, + alignItems: "center", + justifyContent: "center", + }, + hidden: { + opacity: 0, + }, + bulletMarker: { + width: 7, + height: 7, + borderRadius: 999, + borderWidth: 0.5, + marginLeft: 10, + marginRight: 10, + marginTop: 6, + }, +}); diff --git a/frontend/components/MediaGallery.tsx b/frontend/components/MediaGallery.tsx new file mode 100644 index 0000000..5b17dd6 --- /dev/null +++ b/frontend/components/MediaGallery.tsx @@ -0,0 +1,262 @@ +import React from "react"; +import { + ActivityIndicator, + Modal, + Pressable, + StyleSheet, + View, +} from "react-native"; +import { VideoView, useVideoPlayer } from "expo-video"; +import { WebView } from "react-native-webview"; +import * as Linking from "expo-linking"; +import { FadeInImage } from "@/components/FadeInImage"; +import { LazyFadeIn } from "@/components/LazyFadeIn"; +import { ThemedText } from "@/components/ThemedText"; +import { useAppColors } from "@/hooks/useAppColors"; +import { useDeferredRender } from "@/hooks/useDeferredRender"; +import { resolveMediaUrl } from "@/services/api"; + +type MediaGalleryProps = { + media?: string[]; +}; + +const imageExtensions = ["jpg", "jpeg", "png", "gif", "webp", "heic", "heif"]; +const videoExtensions = ["mp4", "mov", "webm", "avi", "m4v"]; + +const getExtension = (url: string) => { + const clean = url.split("?")[0].split("#")[0]; + const parts = clean.split("."); + if (parts.length < 2) { + return ""; + } + return parts[parts.length - 1].toLowerCase(); +}; + +const isSvg = (url: string) => getExtension(url) === "svg"; +const isImage = (url: string) => imageExtensions.includes(getExtension(url)); +const isVideo = (url: string) => videoExtensions.includes(getExtension(url)); +const ensureUrlScheme = (url: string) => + /^[a-z][a-z0-9+.-]*:/i.test(url) ? url : `https://${url}`; + +type VideoItemProps = { + source: string; +}; + +type SvgItemProps = { + source: string; + isReady: boolean; +}; + +function VideoItem({ source }: VideoItemProps) { + const player = useVideoPlayer(source, (instance) => { + instance.loop = true; + }); + + return ( + + ); +} +function SvgItem({ source, isReady }: SvgItemProps) { + const colors = useAppColors(); + const [svgMarkup, setSvgMarkup] = React.useState(null); + const [isLoaded, setIsLoaded] = React.useState(false); + + React.useEffect(() => { + let active = true; + setSvgMarkup(null); + setIsLoaded(false); + + void fetch(source) + .then((response) => { + if (!response.ok) { + throw new Error("Failed to load SVG"); + } + return response.text(); + }) + .then((text) => { + if (active) { + setSvgMarkup(text); + } + }) + .catch(() => { + if (active) { + setSvgMarkup(null); + } + }); + + return () => { + active = false; + }; + }, [source]); + + const html = React.useMemo(() => { + const content = svgMarkup + ? svgMarkup + : ``; + return `${content}`; + }, [source, svgMarkup]); + + return ( + + {isReady ? ( + + setIsLoaded(true)} + /> + {!isLoaded ? ( + + + + ) : null} + + ) : null} + + ); +} + +export function MediaGallery({ media }: MediaGalleryProps) { + const colors = useAppColors(); + const isReady = useDeferredRender(); + const [expandedImage, setExpandedImage] = React.useState(null); + if (!media?.length) { + return null; + } + + const normalizedMedia = media + .map((item) => resolveMediaUrl(item)) + .filter(Boolean); + + return ( + + {normalizedMedia.map((item) => { + if (isVideo(item)) { + return ( + + {isReady ? : null} + + ); + } + + if (isSvg(item)) { + return ; + } + + if (isImage(item)) { + return ( + setExpandedImage(item)}> + + {isReady ? ( + + ) : null} + + + ); + } + + return ( + Linking.openURL(ensureUrlScheme(item))} + style={[styles.linkCard, { borderColor: colors.border }]} + > + + {item} + + + ); + })} + setExpandedImage(null)} + > + setExpandedImage(null)} + > + {expandedImage ? ( + + ) : null} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + gap: 10, + }, + media: { + width: "100%", + height: 200, + borderRadius: 12, + backgroundColor: "transparent", + }, + video: { + backgroundColor: "transparent", + }, + svg: { + height: 180, + backgroundColor: "transparent", + }, + svgContainer: { + borderRadius: 12, + overflow: "hidden", + }, + svgWebView: { + width: "100%", + height: "100%", + backgroundColor: "transparent", + }, + svgLoadingOverlay: { + ...StyleSheet.absoluteFillObject, + alignItems: "center", + justifyContent: "center", + }, + hidden: { + opacity: 0, + }, + linkCard: { + borderRadius: 10, + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 10, + }, + modalBackdrop: { + flex: 1, + backgroundColor: "rgba(0, 0, 0, 0.9)", + alignItems: "center", + justifyContent: "center", + padding: 24, + }, + modalImage: { + width: "100%", + height: "100%", + }, +}); diff --git a/frontend/components/Post.tsx b/frontend/components/Post.tsx index a70e1c5..0a63ff6 100644 --- a/frontend/components/Post.tsx +++ b/frontend/components/Post.tsx @@ -1,283 +1,979 @@ -import { useState, useEffect, useRef } from "react"; -import { View, Text, StyleSheet, Animated } from "react-native"; -import { Card, Icon, CheckBox } from "@rneui/themed"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { + ActivityIndicator, + Alert, + Animated, + Pressable, + StyleSheet, + TextInput, + View, +} from "react-native"; +import { Feather } from "@expo/vector-icons"; +import { LinearGradient } from "expo-linear-gradient"; +import { UiPost } from "@/constants/Types"; +import { FadeInImage } from "@/components/FadeInImage"; +import { LazyFadeIn } from "@/components/LazyFadeIn"; import { ThemedText } from "@/components/ThemedText"; -import { useThemeColor } from "@/hooks/useThemeColor"; -import { PostProps, CommentProps } from "@/constants/Types"; - -function Like() { - const [checked, setChecked] = useState(false); - const scaleValue = useRef(new Animated.Value(1)).current; - - const toggleLike = () => { - Animated.sequence([ - Animated.timing(scaleValue, { - toValue: 1.2, - duration: 100, - useNativeDriver: true, - }), - Animated.timing(scaleValue, { - toValue: 1, - duration: 200, - useNativeDriver: true, - }), - ]).start(); - setChecked(!checked); - }; - return ( - - - } - uncheckedIcon={ - - } - checked={checked} - onPress={toggleLike} - /> - - ); -} -function Comment({ onPress }: { onPress: () => void }) { - const [checked, setChecked] = useState(false); - const scaleValue = useRef(new Animated.Value(1)).current; - - const toggleComment = () => { - Animated.sequence([ - Animated.timing(scaleValue, { - toValue: 1.2, - duration: 100, - useNativeDriver: true, - }), - Animated.timing(scaleValue, { - toValue: 1, - duration: 200, - useNativeDriver: true, - }), - ]).start(); - setChecked(!checked); - onPress(); - }; +import { TagChip } from "@/components/TagChip"; +import { MarkdownText } from "@/components/MarkdownText"; +import { MediaGallery } from "@/components/MediaGallery"; +import { useAppColors } from "@/hooks/useAppColors"; +import { useAuth } from "@/contexts/AuthContext"; +import { useSaved } from "@/contexts/SavedContext"; +import { useRouter } from "expo-router"; +import { + getPostById, + getCommentsByPostId, + isPostLiked, + likePost, + deletePost, + resolveMediaUrl, + updatePost, + uploadMedia, + unlikePost, +} from "@/services/api"; +import { + emitPostDeleted, + emitPostStats, + emitPostUpdated, + subscribeToPostEvents, +} from "@/services/postEvents"; +import * as DocumentPicker from "expo-document-picker"; +import * as ImagePicker from "expo-image-picker"; - return ( - - - } - uncheckedIcon={ - - } - checked={checked} - onPress={toggleComment} - /> - - ); +const postCommentCountCache = new Map(); +const postCommentCountInFlight = new Map>(); + +const getCachedCommentCount = async (postId: number) => { + const cached = postCommentCountCache.get(postId); + if (typeof cached === "number") { + return cached; + } + + const pending = postCommentCountInFlight.get(postId); + if (pending) { + return pending; + } + + const request = getCommentsByPostId(postId) + .then((list) => { + const count = Array.isArray(list) ? list.length : 0; + postCommentCountCache.set(postId, count); + return count; + }) + .catch(() => 0) + .finally(() => { + postCommentCountInFlight.delete(postId); + }); + + postCommentCountInFlight.set(postId, request); + return request; +}; + +type PostProps = UiPost; + +type PostActionProps = { + icon: string; + label: string | number; + onPress?: () => void; + color?: string; + glow?: boolean; +}; + +function PostAction({ icon, label, onPress, color, glow }: PostActionProps) { + const colors = useAppColors(); + const iconColor = color ?? colors.muted; + const scale = useRef(new Animated.Value(1)).current; + const glowAnim = useRef(new Animated.Value(glow ? 1 : 0)).current; + + useEffect(() => { + Animated.timing(glowAnim, { + toValue: glow ? 1 : 0, + duration: 180, + useNativeDriver: false, + }).start(); + }, [glow, glowAnim]); + + const animateTo = (toValue: number) => { + Animated.spring(scale, { + toValue, + speed: 22, + bounciness: 5, + useNativeDriver: true, + }).start(); + }; + + return ( + + + animateTo(0.94)} + onPressOut={() => animateTo(1)} + style={({ pressed }) => [ + styles.actionTouch, + pressed && styles.actionPressed, + ]} + onPress={onPress} + > + + + {label} + + + + + ); } export function Post({ - id, - user, - project, - likes, - content, - comments, - created_on, + id, + username, + projectId, + projectName, + projectStage, + likes, + saves, + comments, + content, + media, + created_on, + tags, + userPicture, + userId, }: PostProps) { - const [isCommentSectionVisible, setIsCommentSectionVisible] = useState(false); - const [commentData, setCommentData] = useState([]); + const colors = useAppColors(); + const router = useRouter(); + const { user } = useAuth(); + const { isSaved, savedPostIds, toggleSave } = useSaved(); + const [likeCount, setLikeCount] = useState(likes); + const [saveCount, setSaveCount] = useState(saves); + const [commentCount, setCommentCount] = useState(comments); + const [isLiked, setIsLiked] = useState(false); + const [isLikeUpdating, setIsLikeUpdating] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [draft, setDraft] = useState(content); + const [localContent, setLocalContent] = useState(content); + const [localMedia, setLocalMedia] = useState(media ?? []); + const [editMedia, setEditMedia] = useState(media ?? []); + const [isUploadingMedia, setIsUploadingMedia] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const likeScale = useRef(new Animated.Value(1)).current; + const resolvedUserPicture = resolveMediaUrl(userPicture); + const [userPicFailed, setUserPicFailed] = useState(false); + const longContentThreshold = 520; + const previewCharLimit = 220; + const likeMutationRef = useRef<{ value: boolean; ts: number } | null>(null); + const saveMutationRef = useRef<{ value: boolean; ts: number } | null>(null); + const desiredLikeRef = useRef(null); + const desiredSaveRef = useRef(null); + const [isSavedLocal, setIsSavedLocal] = useState(isSaved(id)); + const previewContent = useMemo(() => { + if (localContent.length <= previewCharLimit) { + return localContent; + } + return localContent.slice(0, previewCharLimit).trimEnd(); + }, [localContent, previewCharLimit]); + const isTruncated = localContent.length > previewCharLimit; + const isLongContent = localContent.length > longContentThreshold; + const renderedContent = isLongContent ? previewContent : localContent; + + const initials = useMemo(() => { + return username + .split(" ") + .map((part) => part[0]) + .join("") + .slice(0, 2) + .toUpperCase(); + }, [username]); - const toggleCommentSection = () => { - setIsCommentSectionVisible(!isCommentSectionVisible); + const timeLabel = new Date(created_on).toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + }); + const dateLabel = new Date(created_on).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); + + const handleOpenProfile = () => { + if (!username) { + return; + } + router.push({ pathname: "/user/[username]", params: { username } }); + }; + + const handleOpenStream = () => { + if (!projectId) { + return; + } + router.push({ + pathname: "/stream/[projectId]", + params: { projectId: String(projectId) }, + }); + }; + + const getMediaLabel = (url: string) => { + const trimmed = url.split("?")[0].split("#")[0]; + return trimmed.split("/").pop() || "attachment"; + }; + + useEffect(() => { + if (!isEditing) { + setDraft(content); + setLocalContent(content); + setLocalMedia(media ?? []); + setEditMedia(media ?? []); + } + }, [content, isEditing, media]); + + useEffect(() => { + setIsSavedLocal(isSaved(id)); + }, [id, isSaved, savedPostIds]); + + useEffect(() => { + const pending = likeMutationRef.current; + if (pending && Date.now() - pending.ts < 1800) { + return; + } + setLikeCount(likes); + }, [likes]); + + useEffect(() => { + setSaveCount(saves); + }, [saves]); + + useEffect(() => { + setCommentCount((prev) => { + if (comments > 0 || (comments === 0 && prev === 0)) { + return comments; + } + return prev; + }); + }, [comments]); + + useEffect(() => { + return subscribeToPostEvents((event) => { + if (event.postId !== id || event.type !== "stats") { + return; + } + if (typeof event.likes === "number") { + setLikeCount(event.likes); + } + if (typeof event.comments === "number") { + postCommentCountCache.set(id, event.comments); + setCommentCount(event.comments); + } + if (typeof event.isLiked === "boolean") { + const pending = likeMutationRef.current; + if (pending && Date.now() - pending.ts < 1500) { + if (event.isLiked === pending.value) { + likeMutationRef.current = null; + } else { + return; + } + } + setIsLiked(event.isLiked); + } + }); + }, [id]); + + useEffect(() => { + let isMounted = true; + const loadMeta = async () => { + if (!user?.username) { + const nextCount = await getCachedCommentCount(id); + if (isMounted) { + setCommentCount(nextCount); + } + return; + } + try { + const [likeStatus, nextCount] = await Promise.all([ + isPostLiked(user.username, id), + getCachedCommentCount(id), + ]); + if (isMounted) { + const pending = likeMutationRef.current; + if (!pending || Date.now() - pending.ts >= 1500) { + setIsLiked(likeStatus.status); + } + setCommentCount(nextCount); + } + } catch { + if (isMounted) { + setIsLiked(false); + const nextCount = await getCachedCommentCount(id); + setCommentCount(nextCount); + } + } + }; + + loadMeta(); + return () => { + isMounted = false; }; - useEffect(() => { - if (isCommentSectionVisible) { - const fetchedComments: CommentProps[] = [ - // API CALL - { id: 1, user: 1, post: id, likes: 5, parent_comment: 0, created_on: '2025-01-01T00:00:00Z', content: "Great post!" }, - { id: 2, user: 2, post: id, likes: 2, parent_comment: 0, created_on: '2025-01-02T00:00:00Z', content: "I agree!" } - ]; - setCommentData(fetchedComments); + }, [id, user?.username]); + + const handleToggleLike = async () => { + if (!user?.username) { + return; + } + + let nextLiked = false; + let nextLikes = 0; + + setIsLiked((prev) => { + nextLiked = !prev; + return nextLiked; + }); + setLikeCount((prev) => { + nextLikes = Math.max(0, prev + (nextLiked ? 1 : -1)); + return nextLikes; + }); + + emitPostStats(id, { likes: nextLikes, isLiked: nextLiked }); + likeMutationRef.current = { value: nextLiked, ts: Date.now() }; + desiredLikeRef.current = nextLiked; + + if (isLikeUpdating) { + return; + } + + setIsLikeUpdating(true); + try { + while (desiredLikeRef.current !== null) { + const targetLiked = desiredLikeRef.current; + desiredLikeRef.current = null; + + if (targetLiked) { + await likePost(user.username, id); + } else { + await unlikePost(user.username, id); } - }, [isCommentSectionVisible, id]); + } - const cardBackgroundColor = useThemeColor( - { light: "light grey", dark: "#151515" }, - "background" - ); - let CreationDate = new Date(created_on); + try { + const [serverStatus, serverPost] = await Promise.all([ + isPostLiked(user.username, id), + getPostById(id), + ]); + setIsLiked(serverStatus.status); + if (typeof serverPost?.likes === "number") { + const normalizedLikes = Math.max(0, serverPost.likes); + setLikeCount(normalizedLikes); + emitPostStats(id, { + likes: normalizedLikes, + isLiked: serverStatus.status, + }); + } + } catch {} + } catch { + try { + const [serverStatus, serverPost] = await Promise.all([ + isPostLiked(user.username, id), + getPostById(id), + ]); + setIsLiked(serverStatus.status); + if (typeof serverPost?.likes === "number") { + setLikeCount(Math.max(0, serverPost.likes)); + emitPostStats(id, { + likes: Math.max(0, serverPost.likes), + isLiked: serverStatus.status, + }); + } + } catch {} + } finally { + likeMutationRef.current = null; + setIsLikeUpdating(false); + } + }; - return ( - - - - {user} + const handleToggleSave = async () => { + let nextSaved = false; + let nextCount = 0; + + setIsSavedLocal((prev) => { + nextSaved = !prev; + return nextSaved; + }); + setSaveCount((prev) => { + nextCount = Math.max(0, prev + (nextSaved ? 1 : -1)); + return nextCount; + }); + + saveMutationRef.current = { value: nextSaved, ts: Date.now() }; + desiredSaveRef.current = nextSaved; + + if (isSaving) { + return; + } + + setIsSaving(true); + try { + while (desiredSaveRef.current !== null) { + const targetSaved = desiredSaveRef.current; + desiredSaveRef.current = null; + const currentlySaved = isSaved(id); + if (targetSaved !== currentlySaved) { + await toggleSave(id); + } + } + } catch { + const restoredSaved = isSaved(id); + setIsSavedLocal(restoredSaved); + } finally { + saveMutationRef.current = null; + setIsSaving(false); + } + }; + + useEffect(() => { + if (!isLiked) { + return; + } + likeScale.setValue(1); + Animated.sequence([ + Animated.timing(likeScale, { + toValue: 1.12, + duration: 90, + useNativeDriver: true, + }), + Animated.timing(likeScale, { + toValue: 1, + duration: 110, + useNativeDriver: true, + }), + ]).start(); + }, [isLiked, likeScale]); + + const handleCancelEdit = () => { + setDraft(localContent); + setEditMedia(localMedia); + setIsEditing(false); + }; + + const handleAddEditMedia = async (source: "file" | "library") => { + if (isUploadingMedia) { + return; + } + setIsUploadingMedia(true); + try { + let files: { uri: string; name: string; type: string }[] = []; + if (source === "library") { + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ["images", "videos"], + quality: 0.9, + }); + if (!result.canceled && result.assets?.length) { + files = result.assets.map((asset) => ({ + uri: asset.uri, + name: asset.fileName ?? `media-${Date.now()}`, + type: asset.type ?? "application/octet-stream", + })); + } + } else { + const result = await DocumentPicker.getDocumentAsync({ + type: "*/*", + copyToCacheDirectory: true, + }); + if (!result.canceled) { + files = [ + { + uri: result.assets[0].uri, + name: result.assets[0].name, + type: result.assets[0].mimeType ?? "application/octet-stream", + }, + ]; + } + } + + if (!files.length) { + return; + } + + const uploads = await Promise.all(files.map((file) => uploadMedia(file))); + const urls = uploads.map((item) => item.url); + setEditMedia((prev) => [...prev, ...urls]); + } finally { + setIsUploadingMedia(false); + } + }; + + const handleRemoveEditMedia = (url: string) => { + setEditMedia((prev) => prev.filter((item) => item !== url)); + }; + + const handleSaveEdit = async () => { + if (!draft.trim()) { + Alert.alert("Byte cannot be empty."); + return; + } + setIsUpdating(true); + try { + const response = await updatePost(id, { + content: draft.trim(), + media: editMedia, + }); + const nextContent = response?.post?.content ?? draft.trim(); + const nextMedia = response?.post?.media ?? editMedia; + setLocalContent(nextContent); + setLocalMedia(nextMedia); + setIsEditing(false); + emitPostUpdated(id, nextContent, nextMedia); + } catch { + Alert.alert("Failed to update byte."); + } finally { + setIsUpdating(false); + } + }; + + const handleDelete = () => { + if (isDeleting) { + return; + } + Alert.alert("Delete byte?", "This action cannot be undone.", [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: () => { + void (async () => { + setIsDeleting(true); + try { + await deletePost(id); + emitPostDeleted(id); + } catch { + Alert.alert("Failed to delete byte."); + } finally { + setIsDeleting(false); + setIsEditing(false); + } + })(); + }, + }, + ]); + }; + + const handleOpenPost = () => { + router.push({ + pathname: "/post/[postId]", + params: { postId: String(id) }, + }); + }; + + return ( + + + + {resolvedUserPicture && !userPicFailed ? ( + setUserPicFailed(true)} + /> + ) : ( + + + {initials} + + + )} + + + + {username} + + + + + {projectName} + + + + + + + + + {isEditing ? ( + + + + handleAddEditMedia("library")} + style={[styles.mediaButton, { borderColor: colors.border }]} + disabled={isUploadingMedia} + > + + Add photo/video - - Stream {project} + + handleAddEditMedia("file")} + style={[styles.mediaButton, { borderColor: colors.border }]} + disabled={isUploadingMedia} + > + + Add file + - - {content} + {isUploadingMedia ? ( + + + + Uploading... + + + ) : null} + {editMedia.length ? ( + + {editMedia.map((item) => ( + handleRemoveEditMedia(item)} + style={[styles.mediaChip, { borderColor: colors.border }]} + > + + {getMediaLabel(item)} × + + + ))} + + ) : null} + + + + + + + Delete + + + + + Cancel + + + + {isUpdating ? ( + + ) : ( + + Save + + )} + + + + ) : ( + + + {renderedContent} + {isLongContent && isTruncated ? ( + + ) : null} + + {isLongContent ? ( + + Long byte preview · tap to open full byte - - - - - {likes} likes - - - - {comments} bits - - - - {CreationDate.toLocaleString("en-US", { - weekday: "long", - year: "numeric", - month: "short", - day: "numeric", - hour: "numeric", - minute: "numeric", - hour12: true, - })} - - {isCommentSectionVisible && ( - - {commentData.map((comment) => ( - - {comment.content} - - ))} - {/* Optionally, add a form to post new comments here */} - Add a comment... - - )} - - ); + ) : null} + + )} + + + + {tags.map((tag) => ( + + ))} + + + + + + + + + + + {dateLabel} · {timeLabel} + + + + + ); } const styles = StyleSheet.create({ - card: { - borderRadius: 12, - padding: 10, - marginBottom: 20, - borderColor: "grey", - }, - header: { - flexDirection: "row", - alignItems: "center", - marginBottom: 10, - }, - divider: { - marginTop: 5, - marginBottom: 5, - }, - username: { - fontSize: 20, - fontWeight: "bold", - }, - project: { - fontSize: 14, - marginLeft: 5, - }, - content: { - fontSize: 14, - lineHeight: 20, - marginBottom: 10, - }, - iconStyle: { - backgroundColor: "transparent", - marginRight: 5, - }, - checkboxContainer: { - padding: 1, - backgroundColor: "transparent", - borderWidth: 0, - marginRight: 0, - }, - glowEffect: { - shadowColor: "white", - shadowOffset: { width: 0, height: 0 }, - shadowOpacity: 0.8, - shadowRadius: 10, - }, - footer: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - marginTop: 5, - }, - actionContainer: { - flexDirection: "row", - alignItems: "center", - }, - bottomText: { - fontSize: 14, - color: "grey", - marginLeft: 5, - }, - date: { - fontSize: 12, - color: "grey", - alignSelf: "flex-end", - marginTop: 5, - }, - commentSection: { - marginTop: 10, - paddingLeft: 10, - paddingRight: 10, - }, - comment: { - marginBottom: 8, - padding: 5, - borderBottomWidth: 1, - borderColor: "lightgrey", - }, - commentContent: { - fontSize: 14, - color: "grey", - }, - addComment: { - fontSize: 14, - color: "blue", - marginTop: 10, - }, + card: { + borderRadius: 14, + paddingHorizontal: 12, + paddingVertical: 11, + marginBottom: 10, + borderWidth: 1, + gap: 10, + minHeight: 172, + }, + header: { + flexDirection: "row", + alignItems: "center", + gap: 10, + }, + headerMeta: { + flexDirection: "row", + alignItems: "center", + gap: 10, + flex: 1, + }, + headerActions: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + headerActionsRow: { + flexDirection: "row", + alignItems: "center", + gap: 6, + }, + headerInfo: { + flex: 1, + }, + profileTapTarget: { + alignSelf: "flex-start", + paddingVertical: 2, + paddingRight: 8, + }, + streamTapTarget: { + alignSelf: "flex-start", + flexDirection: "row", + alignItems: "center", + paddingVertical: 4, + paddingRight: 10, + marginTop: 1, + }, + avatar: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: "center", + justifyContent: "center", + }, + avatarText: { + letterSpacing: 1, + }, + username: { + fontSize: 15, + }, + tagRow: { + flexDirection: "row", + flexWrap: "wrap", + gap: 6, + }, + detailsBlock: { + gap: 8, + minHeight: 98, + }, + footer: { + gap: 4, + minHeight: 42, + }, + footerActionsRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + }, + iconButton: { + width: 28, + height: 28, + borderRadius: 14, + alignItems: "center", + justifyContent: "center", + }, + iconButtonPressed: { + opacity: 0.7, + transform: [{ scale: 0.96 }], + }, + action: { + flexDirection: "row", + alignItems: "center", + gap: 6, + }, + actionTouch: { + minHeight: 34, + minWidth: 44, + justifyContent: "center", + alignItems: "center", + flexDirection: "row", + gap: 6, + paddingHorizontal: 4, + }, + actionShell: { + borderRadius: 8, + justifyContent: "center", + }, + actionPressed: { + opacity: 0.78, + }, + editBlock: { + gap: 10, + }, + editInput: { + borderRadius: 12, + borderWidth: 1, + paddingHorizontal: 10, + paddingVertical: 8, + fontFamily: "SpaceMono", + fontSize: 14, + minHeight: 100, + }, + editActions: { + flexDirection: "row", + justifyContent: "flex-end", + gap: 8, + }, + editButton: { + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 8, + borderWidth: 1, + }, + mediaSection: { + gap: 10, + }, + mediaActions: { + flexDirection: "row", + gap: 10, + }, + mediaButton: { + borderRadius: 10, + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 8, + }, + uploadingRow: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + mediaChips: { + flexDirection: "row", + flexWrap: "wrap", + gap: 8, + }, + mediaChip: { + borderRadius: 10, + borderWidth: 1, + paddingHorizontal: 10, + paddingVertical: 6, + }, + contentWrapper: { + position: "relative", + minHeight: 56, + }, + contentClamped: { + maxHeight: 148, + overflow: "hidden", + }, + contentFade: { + position: "absolute", + left: 0, + right: 0, + bottom: 0, + height: 32, + }, }); diff --git a/frontend/components/ProjectCard.tsx b/frontend/components/ProjectCard.tsx new file mode 100644 index 0000000..5227ea1 --- /dev/null +++ b/frontend/components/ProjectCard.tsx @@ -0,0 +1,466 @@ +import React, { useEffect, useRef, useState } from "react"; +import { Animated, Pressable, StyleSheet, View } from "react-native"; +import { Feather } from "@expo/vector-icons"; +import { UiProject } from "@/constants/Types"; +import { ThemedText } from "@/components/ThemedText"; +import { MarkdownText } from "@/components/MarkdownText"; +import { TagChip } from "@/components/TagChip"; +import { useAppColors } from "@/hooks/useAppColors"; +import { useAuth } from "@/contexts/AuthContext"; +import { useSavedStreams } from "@/contexts/SavedStreamsContext"; +import { LazyFadeIn } from "@/components/LazyFadeIn"; +import { isProjectLiked, likeProject, unlikeProject } from "@/services/api"; +import { + emitProjectStats, + subscribeToProjectEvents, +} from "@/services/projectEvents"; +import { useRouter } from "expo-router"; + +type ProjectCardProps = { + project: UiProject; + variant?: "compact" | "full"; + saved?: boolean; + isBuilder?: boolean; + onSavedChange?: (saved: boolean) => void; +}; + +type RetroActionButtonProps = { + icon: keyof typeof Feather.glyphMap; + label: string | number; + active?: boolean; + onPress: () => void; +}; + +function RetroActionButton({ + icon, + label, + active, + onPress, +}: RetroActionButtonProps) { + const colors = useAppColors(); + const scale = useRef(new Animated.Value(1)).current; + const glowAnim = useRef(new Animated.Value(active ? 1 : 0)).current; + + useEffect(() => { + Animated.timing(glowAnim, { + toValue: active ? 1 : 0, + duration: 180, + useNativeDriver: false, + }).start(); + }, [active, glowAnim]); + + const animateTo = (toValue: number) => { + Animated.spring(scale, { + toValue, + speed: 22, + bounciness: 5, + useNativeDriver: true, + }).start(); + }; + + return ( + + + animateTo(0.94)} + onPressOut={() => animateTo(1)} + onPress={onPress} + style={({ pressed }) => [ + styles.metaTouch, + pressed && styles.metaPressed, + ]} + > + + + {label} + + + + + ); +} + +export function ProjectCard({ + project, + variant = "compact", + saved, + isBuilder, + onSavedChange, +}: ProjectCardProps) { + const colors = useAppColors(); + const router = useRouter(); + const { user } = useAuth(); + const { isSaved: isStreamSaved, toggleSave } = useSavedStreams(); + const isCreator = + typeof project.ownerId === "number" && user?.id === project.ownerId; + const [isLiked, setIsLiked] = useState(false); + const [likeCount, setLikeCount] = useState(project.likes); + const [isUpdating, setIsUpdating] = useState(false); + const [isSaved, setIsSaved] = useState(Boolean(saved)); + const [isSaving, setIsSaving] = useState(false); + const [saveCount, setSaveCount] = useState(project.saves ?? 0); + const likeMutationRef = useRef<{ value: boolean; ts: number } | null>(null); + const desiredLikeRef = useRef(null); + const desiredSaveRef = useRef(null); + const pendingEmitRef = useRef<{ + likes?: number; + saves?: number; + isLiked?: boolean; + } | null>(null); + const summaryCharLimit = 220; + const summaryText = + project.summary.length > summaryCharLimit + ? `${project.summary.slice(0, summaryCharLimit).trimEnd()}…` + : project.summary; + const likeScale = useRef(new Animated.Value(1)).current; + + useEffect(() => { + if (typeof saved === "boolean") { + setIsSaved(saved); + return; + } + setIsSaved(isStreamSaved(project.id)); + }, [isStreamSaved, project.id, saved]); + + useEffect(() => { + setLikeCount(project.likes ?? 0); + }, [project.id, project.likes]); + + useEffect(() => { + setSaveCount(project.saves ?? 0); + }, [project.id, project.saves]); + + useEffect(() => { + let isMounted = true; + const loadLike = async () => { + if (!user?.username) { + return; + } + try { + const status = await isProjectLiked(user.username, project.id); + if (isMounted) { + const mutation = likeMutationRef.current; + if ( + mutation && + mutation.value === status.status && + Date.now() - mutation.ts < 2000 + ) { + return; + } + setIsLiked(status.status); + } + } catch { + if (isMounted) { + setIsLiked(false); + } + } + }; + loadLike(); + return () => { + isMounted = false; + }; + }, [project.id, saved, user?.username]); + + const handleLikeToggle = async () => { + if (!user?.username) { + return; + } + + const nextLiked = !isLiked; + const nextLikes = Math.max(0, likeCount + (nextLiked ? 1 : -1)); + likeMutationRef.current = { value: nextLiked, ts: Date.now() }; + setIsLiked(nextLiked); + setLikeCount(nextLikes); + desiredLikeRef.current = nextLiked; + pendingEmitRef.current = { + ...(pendingEmitRef.current ?? {}), + likes: nextLikes, + isLiked: nextLiked, + }; + + if (isUpdating) { + return; + } + + setIsUpdating(true); + try { + while (desiredLikeRef.current !== null) { + const targetLiked = desiredLikeRef.current; + desiredLikeRef.current = null; + + if (targetLiked) { + await likeProject(user.username, project.id); + } else { + await unlikeProject(user.username, project.id); + } + } + } catch { + try { + const status = await isProjectLiked(user.username, project.id); + setIsLiked(status.status); + } catch {} + const rollbackLikes = Math.max(0, likeCount); + pendingEmitRef.current = { + ...(pendingEmitRef.current ?? {}), + likes: rollbackLikes, + isLiked: isLiked, + }; + } finally { + setIsUpdating(false); + } + }; + + const handleSaveToggle = async () => { + if (!user?.username) { + return; + } + const nextSaved = !isSaved; + const nextCount = Math.max(0, saveCount + (nextSaved ? 1 : -1)); + setIsSaved(nextSaved); + setSaveCount(nextCount); + desiredSaveRef.current = nextSaved; + pendingEmitRef.current = { + ...(pendingEmitRef.current ?? {}), + saves: nextCount, + }; + + if (isSaving) { + return; + } + + setIsSaving(true); + try { + while (desiredSaveRef.current !== null) { + const targetSaved = desiredSaveRef.current; + desiredSaveRef.current = null; + const currentlySaved = isStreamSaved(project.id); + if (targetSaved !== currentlySaved) { + await toggleSave(project.id); + onSavedChange?.(targetSaved); + } + } + } catch { + const fallbackSaved = isStreamSaved(project.id); + setIsSaved(fallbackSaved); + setSaveCount(Math.max(0, saveCount)); + pendingEmitRef.current = { + ...(pendingEmitRef.current ?? {}), + saves: Math.max(0, saveCount), + }; + } finally { + setIsSaving(false); + } + }; + + useEffect(() => { + if (!isLiked) { + return; + } + likeScale.setValue(1); + Animated.sequence([ + Animated.timing(likeScale, { + toValue: 1.12, + duration: 140, + useNativeDriver: true, + }), + Animated.timing(likeScale, { + toValue: 1, + duration: 160, + useNativeDriver: true, + }), + ]).start(); + }, [isLiked, likeScale]); + + useEffect(() => { + return subscribeToProjectEvents((event) => { + if (event.type !== "stats" || event.projectId !== project.id) { + return; + } + if (typeof event.likes === "number") { + setLikeCount(event.likes); + } + if (typeof event.saves === "number") { + setSaveCount(event.saves); + } + if (typeof event.isLiked === "boolean") { + setIsLiked(event.isLiked); + } + }); + }, [project.id]); + + useEffect(() => { + if (!pendingEmitRef.current) { + return; + } + const payload = pendingEmitRef.current; + pendingEmitRef.current = null; + emitProjectStats(project.id, payload); + }, [likeCount, project.id, saveCount]); + + return ( + + + router.push({ + pathname: "/stream/[projectId]", + params: { projectId: String(project.id) }, + }) + } + style={[ + styles.card, + variant === "compact" && styles.cardCompact, + variant === "full" && styles.cardFull, + { backgroundColor: colors.surface, borderColor: colors.border }, + ]} + > + + + + {project.name} + + + {project.stage.toUpperCase()} · {project.contributors} builders + + + + + + + {summaryText} + + + + {isCreator ? ( + + ) : isBuilder ? ( + + ) : null} + {project.tags.map((tag) => ( + + ))} + + + + + + + + + + {new Date(project.updated_on).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + })} + {" · "} + {new Date(project.updated_on).toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + })} + + + + + + ); +} + +const styles = StyleSheet.create({ + card: { + borderRadius: 14, + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 11, + gap: 8, + minHeight: 156, + }, + cardCompact: { + minWidth: 220, + maxWidth: 260, + minHeight: 168, + }, + cardFull: { + width: "100%", + alignSelf: "stretch", + minHeight: 168, + }, + headerRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + }, + name: { + fontSize: 15, + }, + stageDot: { + width: 8, + height: 8, + borderRadius: 4, + }, + summary: { + minHeight: 44, + }, + tagRow: { + flexDirection: "row", + flexWrap: "wrap", + gap: 6, + minHeight: 24, + }, + metaRow: { + flexDirection: "row", + gap: 12, + flexWrap: "wrap", + minHeight: 34, + }, + metaItem: { + flexDirection: "row", + alignItems: "center", + gap: 6, + }, + metaTouch: { + minHeight: 34, + minWidth: 44, + paddingHorizontal: 4, + justifyContent: "center", + alignItems: "center", + flexDirection: "row", + gap: 6, + }, + metaPressed: { + opacity: 0.78, + }, +}); diff --git a/frontend/components/ScrollToTopButton.tsx b/frontend/components/ScrollToTopButton.tsx index 4b94fbe..40150c8 100644 --- a/frontend/components/ScrollToTopButton.tsx +++ b/frontend/components/ScrollToTopButton.tsx @@ -1,30 +1,43 @@ import React from "react"; -import { StyleSheet, ScrollView, TouchableOpacity } from "react-native"; -import { Icon } from "@rneui/themed"; +import { ScrollView, StyleSheet, TouchableOpacity } from "react-native"; +import { Feather } from "@expo/vector-icons"; +import * as Haptics from "expo-haptics"; +import { useAppColors } from "@/hooks/useAppColors"; interface ScrollToTopButtonProps { scrollViewRef: React.RefObject; } -const ScrollToTopButton: React.FC = ({ scrollViewRef }) => { +const ScrollToTopButton: React.FC = ({ + scrollViewRef, +}) => { + const colors = useAppColors(); + const scrollToTop = () => { - scrollViewRef.current?.scrollTo({ y: 0, animated: true}); - + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + scrollViewRef.current?.scrollTo({ y: 0, animated: true }); }; return ( - - + + ); }; const styles = StyleSheet.create({ button: { - backgroundColor: "#16ff00", padding: 10, borderRadius: 30, zIndex: 2, + borderWidth: 1, }, }); diff --git a/frontend/components/ScrollView.tsx b/frontend/components/ScrollView.tsx index 65aa67c..2556dad 100644 --- a/frontend/components/ScrollView.tsx +++ b/frontend/components/ScrollView.tsx @@ -1,4 +1,4 @@ -import type { PropsWithChildren, ReactElement } from "react"; +import type { PropsWithChildren } from "react"; import { StyleSheet, SafeAreaView } from "react-native"; import Animated, { useAnimatedRef } from "react-native-reanimated"; import { ThemedView } from "@/components/ThemedView"; @@ -6,7 +6,7 @@ import { useBottomTabOverflow } from "@/components/ui/TabBarBackground"; const HEADER_HEIGHT = 250; -type Props = PropsWithChildren<{}>; +type Props = PropsWithChildren; export default function ScrollView({ children }: Props) { const scrollRef = useAnimatedRef(); @@ -41,7 +41,8 @@ const styles = StyleSheet.create({ }, content: { flex: 1, - padding: 32, + paddingVertical: 32, + paddingHorizontal: 0, gap: 16, overflow: "hidden", }, diff --git a/frontend/components/SectionHeader.tsx b/frontend/components/SectionHeader.tsx new file mode 100644 index 0000000..043b3c3 --- /dev/null +++ b/frontend/components/SectionHeader.tsx @@ -0,0 +1,63 @@ +import React from "react"; +import { Pressable, StyleSheet, View } from "react-native"; +import { Feather } from "@expo/vector-icons"; +import { ThemedText } from "@/components/ThemedText"; +import { useAppColors } from "@/hooks/useAppColors"; + +type SectionHeaderProps = { + title: string; + actionLabel?: string; + actionOnPress?: () => void; +}; + +export function SectionHeader({ + title, + actionLabel, + actionOnPress, +}: SectionHeaderProps) { + const colors = useAppColors(); + + return ( + + + {title} + + {actionLabel ? ( + [styles.action, pressed && styles.pressed]} + onPress={actionOnPress} + > + + {actionLabel} + + + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + marginBottom: 10, + }, + title: { + fontSize: 16, + }, + action: { + flexDirection: "row", + alignItems: "center", + gap: 6, + marginRight: 16, + }, + pressed: { + opacity: 0.8, + transform: [{ translateY: -1 }], + }, +}); diff --git a/frontend/components/StatPill.tsx b/frontend/components/StatPill.tsx new file mode 100644 index 0000000..4d0a291 --- /dev/null +++ b/frontend/components/StatPill.tsx @@ -0,0 +1,51 @@ +import React from "react"; +import { StyleSheet, View } from "react-native"; +import { ThemedText } from "@/components/ThemedText"; +import { useAppColors } from "@/hooks/useAppColors"; + +type StatPillProps = { + label: string; + value: string | number; +}; + +export function StatPill({ label, value }: StatPillProps) { + const colors = useAppColors(); + + return ( + + + {label} + + + {value} + + + ); +} + +const styles = StyleSheet.create({ + pill: { + borderRadius: 10, + paddingVertical: 8, + paddingHorizontal: 10, + gap: 2, + minWidth: 74, + borderWidth: 1, + shadowOpacity: 0.2, + shadowRadius: 6, + shadowOffset: { width: 0, height: 0 }, + elevation: 2, + }, + value: { + fontSize: 16, + }, +}); diff --git a/frontend/components/TagChip.tsx b/frontend/components/TagChip.tsx new file mode 100644 index 0000000..ad58a3a --- /dev/null +++ b/frontend/components/TagChip.tsx @@ -0,0 +1,76 @@ +import React from "react"; +import { StyleSheet, View } from "react-native"; +import { ThemedText } from "@/components/ThemedText"; +import { useAppColors } from "@/hooks/useAppColors"; +import { useColorScheme } from "@/hooks/useColorScheme"; + +type TagChipProps = { + label: string; + tone?: "default" | "accent"; +}; + +export function TagChip({ label, tone = "default" }: TagChipProps) { + const colors = useAppColors(); + const theme = useColorScheme() ?? "light"; + + const liftHex = (hex: string, amount: number) => { + const normalized = hex.replace("#", "").trim(); + const value = + normalized.length === 3 + ? normalized + .split("") + .map((chunk) => `${chunk}${chunk}`) + .join("") + : normalized.padEnd(6, "0").slice(0, 6); + + const mix = (channel: number) => + Math.round(channel + (255 - channel) * amount) + .toString(16) + .padStart(2, "0"); + + const red = parseInt(value.slice(0, 2), 16); + const green = parseInt(value.slice(2, 4), 16); + const blue = parseInt(value.slice(4, 6), 16); + return `#${mix(red)}${mix(green)}${mix(blue)}`; + }; + + const backgroundColor = + tone === "accent" + ? colors.tint + : theme === "dark" + ? liftHex(colors.chip, 0.08) + : colors.chip; + const textColor = tone === "accent" ? colors.accent : colors.chipText; + + return ( + + + {label} + + + ); +} + +const styles = StyleSheet.create({ + chip: { + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: 8, + }, + text: { + textTransform: "uppercase", + letterSpacing: 0.5, + }, +}); diff --git a/frontend/components/ThemedText.tsx b/frontend/components/ThemedText.tsx index c0e1a78..f933f23 100644 --- a/frontend/components/ThemedText.tsx +++ b/frontend/components/ThemedText.tsx @@ -1,60 +1,364 @@ -import { Text, type TextProps, StyleSheet } from 'react-native'; +import { useEffect, useRef, useState } from "react"; +import { Animated, useWindowDimensions, type TextProps } from "react-native"; -import { useThemeColor } from '@/hooks/useThemeColor'; +import { useThemeColor } from "@/hooks/useThemeColor"; +import { usePreferences } from "@/contexts/PreferencesContext"; +import { useMotionConfig } from "@/hooks/useMotionConfig"; export type ThemedTextProps = TextProps & { lightColor?: string; darkColor?: string; - type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link'; + animateOnMount?: boolean; + animationMode?: "fade" | "typewriter" | "wave" | "none" | "auto"; + type?: + | "default" + | "title" + | "defaultSemiBold" + | "subtitle" + | "link" + | "display" + | "label" + | "caption"; }; export function ThemedText({ style, lightColor, darkColor, - type = 'default', + animateOnMount, + animationMode = "auto", + type = "default", + children, + onLayout, ...rest }: ThemedTextProps) { - const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text'); + const { fontScale } = useWindowDimensions(); + const { preferences } = usePreferences(); + const motion = useMotionConfig(); + const fadeOpacity = useRef(new Animated.Value(1)).current; + const fadeTranslateY = useRef(new Animated.Value(0)).current; + const fadeKeyRef = useRef(null); + const typeTimerRef = useRef | null>(null); + const wavePulseRef = useRef(null); + const typewriterKeyRef = useRef(null); + const waveKeyRef = useRef(null); + const waveOpacity = useRef(new Animated.Value(1)).current; + const waveTranslateY = useRef(new Animated.Value(0)).current; + const color = useThemeColor( + { light: lightColor, dark: darkColor }, + type === "link" ? "tint" : "text", + ); + const rawTextChild = + typeof children === "string" || typeof children === "number" + ? String(children) + : null; + + const chooseDeterministicMode = (text: string) => { + let hash = 0; + for (let index = 0; index < text.length; index += 1) { + hash = (hash * 31 + text.charCodeAt(index)) >>> 0; + } + const modes: ("fade" | "typewriter" | "wave")[] = [ + "fade", + "typewriter", + "wave", + ]; + return modes[hash % modes.length]; + }; + + const preferenceMode = + preferences.textRenderEffect === "off" + ? "none" + : preferences.textRenderEffect === "smooth" + ? "fade" + : preferences.textRenderEffect; + + const resolvedMode = + animationMode === "auto" + ? preferenceMode === "random" + ? rawTextChild + ? chooseDeterministicMode(rawTextChild) + : "fade" + : preferenceMode + : animationMode; + + const shouldAnimate = + (() => { + const headingLike = + type === "display" || + type === "title" || + type === "subtitle" || + type === "label"; + const effectMode = + resolvedMode === "typewriter" || resolvedMode === "wave"; + return animateOnMount ?? (headingLike || effectMode); + })() && + !motion.prefersReducedMotion && + resolvedMode !== "none"; + + const canTypewriteLength = (rawTextChild?.length ?? 0) <= 180; + const shouldTypewrite = + resolvedMode === "typewriter" && + !!rawTextChild && + canTypewriteLength && + shouldAnimate; + const shouldWave = + resolvedMode === "wave" && + !!rawTextChild && + canTypewriteLength && + shouldAnimate; + const shouldFade = resolvedMode === "fade" && shouldAnimate; + const [typedText, setTypedText] = useState(rawTextChild ?? ""); + const compactScale = preferences.compactMode ? 0.94 : 1; + const accessibleScale = Math.min(1.35, Math.max(1, fontScale || 1)); + const responsiveScale = compactScale * accessibleScale; + const baseStyle = styles[type as keyof typeof styles] ?? styles.default; + const lineHeight = + "lineHeight" in baseStyle ? baseStyle.lineHeight : undefined; + const scaledStyle = + responsiveScale === 1 + ? baseStyle + : { + ...baseStyle, + fontSize: Math.round(baseStyle.fontSize * responsiveScale), + lineHeight: lineHeight + ? Math.round(lineHeight * responsiveScale) + : undefined, + }; + + useEffect(() => { + if (!shouldFade) { + fadeKeyRef.current = rawTextChild; + fadeOpacity.setValue(1); + fadeTranslateY.setValue(0); + return; + } + + const nextKey = rawTextChild ?? `__nontext__${type}`; + if (fadeKeyRef.current === nextKey) { + fadeOpacity.setValue(1); + fadeTranslateY.setValue(0); + return; + } + fadeKeyRef.current = nextKey; + + fadeOpacity.setValue(0.06); + fadeTranslateY.setValue(8); + Animated.parallel([ + Animated.timing(fadeOpacity, { + toValue: 1, + duration: 220, + useNativeDriver: true, + }), + Animated.timing(fadeTranslateY, { + toValue: 0, + duration: 240, + useNativeDriver: true, + }), + ]).start(({ finished }) => { + if (finished) { + fadeOpacity.setValue(1); + fadeTranslateY.setValue(0); + } + }); + }, [fadeOpacity, fadeTranslateY, shouldFade, rawTextChild, type]); + + useEffect(() => { + wavePulseRef.current?.stop(); + wavePulseRef.current = null; + waveOpacity.setValue(1); + waveTranslateY.setValue(0); + + if (shouldWave) { + const pulse = Animated.loop( + Animated.sequence([ + Animated.parallel([ + Animated.timing(waveOpacity, { + toValue: 0.74, + duration: 110, + useNativeDriver: true, + }), + Animated.timing(waveTranslateY, { + toValue: 1.5, + duration: 110, + useNativeDriver: true, + }), + ]), + Animated.parallel([ + Animated.timing(waveOpacity, { + toValue: 1, + duration: 130, + useNativeDriver: true, + }), + Animated.timing(waveTranslateY, { + toValue: 0, + duration: 130, + useNativeDriver: true, + }), + ]), + ]), + ); + wavePulseRef.current = pulse; + pulse.start(); + } + + return () => { + wavePulseRef.current?.stop(); + wavePulseRef.current = null; + waveOpacity.setValue(1); + waveTranslateY.setValue(0); + }; + }, [shouldWave, waveOpacity, waveTranslateY]); + + useEffect(() => { + if (typeTimerRef.current) { + clearInterval(typeTimerRef.current); + typeTimerRef.current = null; + } + + if (!rawTextChild) { + typewriterKeyRef.current = null; + waveKeyRef.current = null; + setTypedText(""); + return; + } + + if (!shouldTypewrite && !shouldWave) { + typewriterKeyRef.current = rawTextChild; + waveKeyRef.current = rawTextChild; + setTypedText(rawTextChild); + return; + } + + if (shouldWave && waveKeyRef.current === rawTextChild) { + setTypedText(rawTextChild); + return; + } + + if (typewriterKeyRef.current === rawTextChild) { + setTypedText(rawTextChild); + return; + } + + const full = rawTextChild; + const total = full.length; + const chunks = shouldWave + ? Math.min(22, Math.max(9, Math.ceil(total / 2.6))) + : Math.min(28, Math.max(10, Math.ceil(total / 2.4))); + const stepBase = Math.max(1, Math.ceil(total / chunks)); + const wavePattern = [1, 2, 1, 3, 2, 1, 2, 3]; + let wavePatternIndex = 0; + let index = 0; + setTypedText(""); + + typeTimerRef.current = setInterval( + () => { + const step = shouldWave + ? Math.max(1, Math.round(stepBase * wavePattern[wavePatternIndex])) + : stepBase; + wavePatternIndex = (wavePatternIndex + 1) % wavePattern.length; + index = Math.min(total, index + step); + const next = full.slice(0, index); + setTypedText(next); + if (index >= total) { + typewriterKeyRef.current = full; + waveKeyRef.current = full; + wavePulseRef.current?.stop(); + wavePulseRef.current = null; + waveOpacity.setValue(1); + waveTranslateY.setValue(0); + if (typeTimerRef.current) { + clearInterval(typeTimerRef.current); + typeTimerRef.current = null; + } + } + }, + shouldWave ? 24 : 14, + ); + + return () => { + if (typeTimerRef.current) { + clearInterval(typeTimerRef.current); + typeTimerRef.current = null; + } + }; + }, [rawTextChild, shouldTypewrite, shouldWave, waveOpacity, waveTranslateY]); return ( - + > + {shouldTypewrite || shouldWave ? typedText : children} + ); } -const styles = StyleSheet.create({ +const styles = { default: { - fontSize: 16, - lineHeight: 24, + fontSize: 15, + lineHeight: 22, + fontFamily: "SpaceMono", }, defaultSemiBold: { - fontSize: 16, - lineHeight: 24, - fontWeight: '600', + fontSize: 15, + lineHeight: 22, + fontWeight: "600", + fontFamily: "SpaceMono", }, title: { - fontSize: 32, - fontWeight: 'bold', - lineHeight: 32, + fontSize: 24, + fontWeight: "700", + lineHeight: 28, + fontFamily: "SpaceMono", }, subtitle: { - fontSize: 20, - fontWeight: 'bold', + fontSize: 16, + fontWeight: "700", + fontFamily: "SpaceMono", }, link: { - lineHeight: 30, - fontSize: 16, - color: '#0a7ea4', + lineHeight: 22, + fontSize: 15, + fontFamily: "SpaceMono", + textDecorationLine: "underline", + textDecorationStyle: "solid", + }, + display: { + fontSize: 28, + fontWeight: "700", + lineHeight: 32, + letterSpacing: -0.3, + fontFamily: "SpaceMono", + }, + label: { + fontSize: 12, + letterSpacing: 1.1, + textTransform: "uppercase", + fontFamily: "SpaceMono", + }, + caption: { + fontSize: 12, + lineHeight: 16, + fontFamily: "SpaceMono", }, -}); +} as const; diff --git a/frontend/components/TopBlur.tsx b/frontend/components/TopBlur.tsx new file mode 100644 index 0000000..ecfffbd --- /dev/null +++ b/frontend/components/TopBlur.tsx @@ -0,0 +1,62 @@ +import React, { useMemo } from "react"; +import { Animated, StyleSheet } from "react-native"; +import { BlurView } from "expo-blur"; +import { LinearGradient } from "expo-linear-gradient"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useColorScheme } from "@/hooks/useColorScheme"; + +type TopBlurProps = { + scrollY?: Animated.Value; +}; + +export function TopBlur({ scrollY }: TopBlurProps) { + const insets = useSafeAreaInsets(); + const theme = useColorScheme() ?? "light"; + const height = Math.max(insets.top, 12) + 28; + const veilColors = useMemo(() => { + if (theme === "dark") { + return ["rgba(0, 0, 0, 0.24)", "rgba(0, 0, 0, 0.12)", "rgba(0, 0, 0, 0)"]; + } + return [ + "rgba(255, 255, 255, 0.2)", + "rgba(255, 255, 255, 0.1)", + "rgba(255, 255, 255, 0)", + ]; + }, [theme]); + const veilStops = [0, 0.5, 1]; + const opacity = scrollY + ? scrollY.interpolate({ + inputRange: [0, 60], + outputRange: [0, 1], + extrapolate: "clamp", + }) + : 0; + + return ( + + + + + ); +} + +const styles = StyleSheet.create({ + blur: { + position: "absolute", + top: 0, + left: 0, + right: 0, + zIndex: 2, + backgroundColor: "transparent", + overflow: "hidden", + }, +}); diff --git a/frontend/components/UnifiedLoading.tsx b/frontend/components/UnifiedLoading.tsx new file mode 100644 index 0000000..791a2f5 --- /dev/null +++ b/frontend/components/UnifiedLoading.tsx @@ -0,0 +1,102 @@ +import React, { useEffect, useRef } from "react"; +import { ActivityIndicator, Animated, StyleSheet, View } from "react-native"; +import { ThemedText } from "@/components/ThemedText"; +import { useAppColors } from "@/hooks/useAppColors"; +import { useMotionConfig } from "@/hooks/useMotionConfig"; + +type UnifiedLoadingListProps = { + rows?: number; + cardHeight?: number; + cardRadius?: number; + gap?: number; +}; + +export function UnifiedLoadingList({ + rows = 3, + cardHeight = 112, + cardRadius = 14, + gap = 12, +}: UnifiedLoadingListProps) { + const colors = useAppColors(); + const motion = useMotionConfig(); + const pulse = useRef(new Animated.Value(0.74)).current; + + useEffect(() => { + if (motion.prefersReducedMotion) { + pulse.setValue(0.88); + return; + } + + const loop = Animated.loop( + Animated.sequence([ + Animated.timing(pulse, { + toValue: 1, + duration: motion.duration(740), + useNativeDriver: true, + }), + Animated.timing(pulse, { + toValue: 0.74, + duration: motion.duration(740), + useNativeDriver: true, + }), + ]), + ); + + loop.start(); + return () => { + loop.stop(); + }; + }, [motion, pulse]); + + return ( + + {Array.from({ length: rows }).map((_, index) => ( + + ))} + + ); +} + +type UnifiedLoadingInlineProps = { + label?: string; +}; + +export function UnifiedLoadingInline({ + label = "Loading…", +}: UnifiedLoadingInlineProps) { + const colors = useAppColors(); + + return ( + + + + {label} + + + ); +} + +const styles = StyleSheet.create({ + card: { + borderWidth: 1, + }, + inlineWrap: { + alignItems: "center", + justifyContent: "center", + flexDirection: "row", + gap: 8, + paddingVertical: 14, + }, +}); diff --git a/frontend/components/User.tsx b/frontend/components/User.tsx index 81451ba..c9d1cfa 100644 --- a/frontend/components/User.tsx +++ b/frontend/components/User.tsx @@ -1,9 +1,13 @@ -import ScrollView from "@/components/ScrollView"; -import TopBar from "@/components/ui/TopBar"; -import { View, Text, StyleSheet, Animated } from "react-native"; +import { useState } from "react"; +import { Alert, Modal, Pressable, StyleSheet, View } from "react-native"; +import * as Linking from "expo-linking"; +import { FadeInImage } from "@/components/FadeInImage"; import { ThemedText } from "@/components/ThemedText"; +import { MarkdownText } from "@/components/MarkdownText"; import { UserProps } from "@/constants/Types"; -import { ExternalPathString, Link } from "expo-router"; +import { useAppColors } from "@/hooks/useAppColors"; +import { usePreferences } from "@/contexts/PreferencesContext"; +import { resolveMediaUrl } from "@/services/api"; export default function User({ username, @@ -12,67 +16,196 @@ export default function User({ created_on, picture, }: UserProps) { - let CreationDate = new Date(created_on); + const colors = useAppColors(); + const { preferences } = usePreferences(); + const creationDate = new Date(created_on); + const hasValidCreationDate = !Number.isNaN(creationDate.getTime()); + const safeLinks = Array.isArray(links) ? links : []; + const resolvedPicture = resolveMediaUrl(picture); + const [pictureFailed, setPictureFailed] = useState(false); + const hasPicture = Boolean(resolvedPicture) && !pictureFailed; + const [isImageOpen, setIsImageOpen] = useState(false); + const openUrlSafe = async (url: string) => { + const trimmed = url.trim(); + if (!/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) { + if (preferences.linkOpenMode === "promptScheme") { + Alert.alert("Open link", `"${trimmed}" is missing a scheme.`, [ + { text: "Cancel", style: "cancel" }, + { + text: "Add http://", + onPress: () => void openUrlSafe(`http://${trimmed}`), + }, + { + text: "Add https://", + onPress: () => void openUrlSafe(`https://${trimmed}`), + }, + ]); + return; + } + const normalizedHost = trimmed.startsWith("www.") + ? trimmed + : `www.${trimmed}`; + await openUrlSafe(`https://${normalizedHost}`); + return; + } + const supported = await Linking.canOpenURL(trimmed); + if (!supported) { + Alert.alert("Unable to open link", trimmed); + return; + } + await Linking.openURL(trimmed); + }; + const initials = username + .split(" ") + .map((part) => part[0]) + .join("") + .slice(0, 2) + .toUpperCase(); + return ( - <> - {/* */} - - - - {username} + + + {hasPicture ? ( + setIsImageOpen(true)} + style={({ pressed }) => [ + styles.avatarButton, + pressed && styles.pressed, + ]} + > + setPictureFailed(true)} + /> + + ) : ( + + {initials} + + )} + + {username} + + Joined{" "} + {hasValidCreationDate + ? creationDate.toLocaleDateString("en-US", { + month: "long", + year: "numeric", + }) + : "Unknown"} - - {bio} - - {links.map((link, index) => ( - - + + {bio?.trim() ? ( + + {bio} + + ) : null} + + {safeLinks.map((link, index) => ( + void openUrlSafe(link)} + style={({ pressed }) => [pressed && styles.pressedInline]} + > + {link} - + ))} - - {CreationDate.toLocaleString("en-US", { - weekday: "long", - year: "numeric", - month: "short", - day: "numeric", - hour: "numeric", - minute: "numeric", - hour12: true, - })} - - - + + setIsImageOpen(false)} + > + setIsImageOpen(false)} + > + + {hasPicture ? ( + + ) : null} + + + + ); } const styles = StyleSheet.create({ + container: { + gap: 12, + minHeight: 132, + }, header: { flexDirection: "row", alignItems: "center", - marginBottom: 10, + gap: 14, + minHeight: 64, + }, + headerText: { + flex: 1, + }, + avatar: { + width: 64, + height: 64, + borderRadius: 32, + alignItems: "center", + justifyContent: "center", + }, + avatarButton: { + borderRadius: 32, + overflow: "hidden", }, - username: { - fontSize: 20, - fontWeight: "bold", + avatarFallback: { + borderWidth: 1, }, bio: { - fontSize: 14, - lineHeight: 20, - marginBottom: 10, + marginTop: 2, + minHeight: 24, }, - date: { - fontSize: 12, - color: "grey", - alignSelf: "flex-end", - marginTop: 5, + links: { + gap: 6, + minHeight: 20, }, link: { - fontSize: 14, - color: "#007AFF", lineHeight: 20, - marginBottom: 10, + }, + viewerBackdrop: { + flex: 1, + backgroundColor: "rgba(0, 0, 0, 0.85)", + alignItems: "center", + justifyContent: "center", + }, + viewerCard: { + width: "100%", + height: "100%", + padding: 24, + }, + viewerImage: { + width: "100%", + height: "100%", + }, + pressed: { + opacity: 0.84, + transform: [{ scale: 0.98 }], + }, + pressedInline: { + opacity: 0.8, }, }); diff --git a/frontend/components/UserCard.tsx b/frontend/components/UserCard.tsx new file mode 100644 index 0000000..68b7dfd --- /dev/null +++ b/frontend/components/UserCard.tsx @@ -0,0 +1,123 @@ +import React from "react"; +import { Pressable, StyleSheet, View } from "react-native"; +import { FadeInImage } from "@/components/FadeInImage"; +import { ThemedText } from "@/components/ThemedText"; +import { useAppColors } from "@/hooks/useAppColors"; +import { resolveMediaUrl } from "@/services/api"; + +type UserCardProps = { + username: string; + picture?: string | null; + isFollowing?: boolean; + onPress?: () => void; + onToggleFollow?: () => void; + showFollow?: boolean; +}; + +export function UserCard({ + username, + picture, + isFollowing, + onPress, + onToggleFollow, + showFollow = true, +}: UserCardProps) { + const colors = useAppColors(); + const resolvedPicture = resolveMediaUrl(picture ?? ""); + const [picFailed, setPicFailed] = React.useState(false); + const showPicture = Boolean(resolvedPicture) && !picFailed; + const initial = username?.[0]?.toUpperCase() ?? "?"; + + return ( + [ + styles.card, + { backgroundColor: colors.surface, borderColor: colors.border }, + pressed && styles.pressed, + ]} + onPress={onPress} + > + + + {showPicture ? ( + setPicFailed(true)} + /> + ) : ( + + {initial} + + )} + + + {username} + + + {showFollow ? ( + [ + styles.followButton, + { backgroundColor: colors.tint }, + pressed && styles.pressed, + ]} + > + + {isFollowing ? "Following" : "Follow"} + + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + card: { + borderRadius: 14, + borderWidth: 1, + padding: 12, + gap: 10, + minHeight: 90, + }, + header: { + flexDirection: "row", + alignItems: "center", + gap: 12, + }, + avatar: { + width: 44, + height: 44, + borderRadius: 22, + alignItems: "center", + justifyContent: "center", + overflow: "hidden", + borderWidth: 1, + }, + avatarImage: { + width: "100%", + height: "100%", + }, + text: { + flex: 1, + minHeight: 20, + }, + followButton: { + alignSelf: "flex-start", + borderRadius: 10, + paddingHorizontal: 10, + paddingVertical: 6, + minHeight: 30, + justifyContent: "center", + }, + pressed: { + opacity: 0.8, + transform: [{ scale: 0.98 }], + }, +}); diff --git a/frontend/components/__tests__/ThemedText-test.tsx b/frontend/components/__tests__/ThemedText-test.tsx index 1ac3225..2a11b4d 100644 --- a/frontend/components/__tests__/ThemedText-test.tsx +++ b/frontend/components/__tests__/ThemedText-test.tsx @@ -1,10 +1,12 @@ -import * as React from 'react'; -import renderer from 'react-test-renderer'; +import * as React from "react"; +import { Text } from "react-native"; +import renderer from "react-test-renderer"; -import { ThemedText } from '../ThemedText'; +import { ThemedText } from "../ThemedText"; -it(`renders correctly`, () => { - const tree = renderer.create(Snapshot test!).toJSON(); +it("renders correctly", () => { + const testRenderer = renderer.create(Snapshot test!); - expect(tree).toMatchSnapshot(); + const textNode = testRenderer.root.findByType(Text); + expect(textNode.props.children).toBe("Snapshot test!"); }); diff --git a/frontend/components/__tests__/__snapshots__/ThemedText-test.tsx.snap b/frontend/components/__tests__/__snapshots__/ThemedText-test.tsx.snap deleted file mode 100644 index b68e53e..0000000 --- a/frontend/components/__tests__/__snapshots__/ThemedText-test.tsx.snap +++ /dev/null @@ -1,24 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`renders correctly 1`] = ` - - Snapshot test! - -`; diff --git a/frontend/components/filter.tsx b/frontend/components/filter.tsx index d733e7b..17a2351 100644 --- a/frontend/components/filter.tsx +++ b/frontend/components/filter.tsx @@ -1,79 +1,170 @@ import React, { useState } from "react"; -import { View, Text, Modal, Animated, Button } from "react-native"; -import { FAB, Icon, Switch } from "@rneui/themed"; +import { + Animated, + Modal, + Pressable, + StyleSheet, + Switch, + View, +} from "react-native"; +import { Feather } from "@expo/vector-icons"; +import * as Haptics from "expo-haptics"; +import { ThemedText } from "@/components/ThemedText"; +import { useAppColors } from "@/hooks/useAppColors"; export const MyFilter: React.FC = () => { const [modalVisible, setModalVisible] = useState(false); - const [slideAnim] = useState(new Animated.Value(-400)); - const [value, setValue] = React.useState(false); + const [slideAnim] = useState(new Animated.Value(40)); + const [value, setValue] = useState(false); + const [trendingOnly, setTrendingOnly] = useState(true); + const colors = useAppColors(); const toggleModal = () => { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); setModalVisible(!modalVisible); if (!modalVisible) { - slideAnim.setValue(-400); + slideAnim.setValue(40); Animated.timing(slideAnim, { toValue: 0, - duration: 100, - useNativeDriver: true, - }).start(); - } else { - Animated.timing(slideAnim, { - toValue: 400, - duration: 100, + duration: 180, useNativeDriver: true, }).start(); } }; + return ( - } - color="#16ff00" - size="large" + + style={({ pressed }) => [ + styles.filterButton, + { backgroundColor: colors.surfaceAlt, borderColor: colors.border }, + pressed && styles.pressed, + ]} + > + + + Filter + + - + + - - Da Filter Options - - setValue(!value)} - /> - + + Already Invited? Install Here + +

+ Button not opening your mail app? Send to + mail@elifouts.net. +

+ + + + + + + diff --git a/frontend/services/api.ts b/frontend/services/api.ts new file mode 100644 index 0000000..f11c70b --- /dev/null +++ b/frontend/services/api.ts @@ -0,0 +1,1159 @@ +import Constants from "expo-constants"; +import * as FileSystem from "expo-file-system"; +import { Platform } from "react-native"; +import { + ApiPost, + ApiProject, + ApiUser, + ApiComment, + ApiDirectMessage, + ApiNotification, + AuthLoginRequest, + AuthRegisterRequest, + AuthResponse, + CreateCommentRequest, + CreateProjectRequest, + CreatePostRequest, + UpdateUserRequest, +} from "@/constants/Types"; + +type ApiUserWire = ApiUser & { + creation_date?: string; + links?: unknown; +}; + +type CachedEntry = { + value: T; + cachedAt: number; +}; + +export type ApiDirectMessageThread = { + peer_username: string; + last_content: string; + last_at: string; +}; + +const userCache = new Map>(); +const inFlightGetRequests = new Map>(); +const MAX_USER_CACHE_ENTRIES = 300; +const MAX_INFLIGHT_GET_ENTRIES = 200; +const USER_CACHE_TTL_MS = 5_000; +let authToken: string | null = null; +const REQUEST_TIMEOUT_MS = 120000; +const UPLOAD_TIMEOUT_MS = 30000; +const HEALTHCHECK_TIMEOUT_MS = 4000; +const LARGE_REQUEST_TIMEOUT_MS = 300000; +const MAX_INLINE_MEDIA_FALLBACK_BYTES = 64 * 1024 * 1024; +const REFRESH_READ_WINDOW_MS = 2500; +let forceFreshReadsUntil = 0; + +type RequestOptions = { + timeoutMs?: number; + forceFresh?: boolean; +}; + +const trimMapToSize = (map: Map, maxEntries: number) => { + if (map.size <= maxEntries) { + return; + } + const excess = map.size - maxEntries; + let removed = 0; + for (const key of map.keys()) { + map.delete(key); + removed += 1; + if (removed >= excess) { + break; + } + } +}; + +const setWithLimit = (map: Map, key: K, value: V, maxEntries: number) => { + if (map.has(key)) { + map.delete(key); + } + map.set(key, value); + trimMapToSize(map, maxEntries); +}; + +const getFreshUserCache = (userId: number) => { + const cached = userCache.get(userId); + if (!cached) { + return null; + } + if (Date.now() - cached.cachedAt > USER_CACHE_TTL_MS) { + userCache.delete(userId); + return null; + } + return cached.value; +}; + +const setUserCache = (user: ApiUser) => { + if (typeof user?.id !== "number" || !Number.isFinite(user.id)) { + return; + } + setWithLimit( + userCache, + user.id, + { + value: user, + cachedAt: Date.now(), + }, + MAX_USER_CACHE_ENTRIES, + ); +}; + +export const upsertCachedUser = (user: ApiUser | null | undefined) => { + if (!user) { + return; + } + setUserCache(user); +}; + +export const invalidateCachedUserById = (userId: number) => { + userCache.delete(userId); +}; + +export const setAuthToken = (token: string | null) => { + const changed = authToken !== token; + authToken = token; + if (changed) { + clearApiCache(); + inFlightGetRequests.clear(); + } +}; + +export const clearApiCache = () => { + userCache.clear(); + inFlightGetRequests.clear(); +}; + +export const beginFreshReadWindow = (durationMs = REFRESH_READ_WINDOW_MS) => { + const windowMs = Math.max(300, durationMs); + forceFreshReadsUntil = Date.now() + windowMs; + inFlightGetRequests.clear(); +}; + +const isFreshReadWindowActive = () => Date.now() <= forceFreshReadsUntil; + +const getHostFromUri = (uri?: string | null) => { + if (!uri) { + return null; + } + const withoutProtocol = uri.replace(/^[a-z]+:\/\//i, ""); + const host = withoutProtocol.split(":")[0]; + return host || null; +}; + +const getDefaultBaseUrl = () => { + // In development, use the local server. + if (__DEV__) { + const legacyConstants = Constants as unknown as { + manifest?: { debuggerHost?: string }; + manifest2?: { extra?: { expoClient?: { hostUri?: string } } }; + }; + + const hostUri = + Constants.expoConfig?.hostUri ?? + legacyConstants.manifest?.debuggerHost ?? + legacyConstants.manifest2?.extra?.expoClient?.hostUri ?? + null; + + const host = getHostFromUri(hostUri); + if (host) { + return `http://${host}`; + } + + if (Platform.OS === "android") { + return "http://10.0.2.2"; + } + + return "http://localhost"; + } + + // In production, use the live server. + return "https://devbits.ddns.net"; +}; + +const normalizeBaseUrl = (url: string) => url.replace(/\/+$/, ""); + +export const API_BASE_URL = normalizeBaseUrl( + process.env.EXPO_PUBLIC_API_URL?.trim() || getDefaultBaseUrl(), +); + +const API_FALLBACK_URL = normalizeBaseUrl( + process.env.EXPO_PUBLIC_API_FALLBACK_URL?.trim() || "https://devbits.ddns.net", +); + +const API_UPLOAD_BASE_URLS = + API_FALLBACK_URL && API_FALLBACK_URL !== API_BASE_URL + ? [API_BASE_URL, API_FALLBACK_URL] + : [API_BASE_URL]; + +const isTransientUploadError = (error: unknown) => { + if (error instanceof Error && error.name === "AbortError") { + return true; + } + const message = + error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase(); + return ( + message.includes("network") || + message.includes("timed out") || + message.includes("failed to fetch") || + message.includes("connection") + ); +}; + +const fetchUploadWithFallback = async ( + path: string, + init: RequestInit, +) => { + let lastError: unknown = null; + + for (const baseUrl of API_UPLOAD_BASE_URLS) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), UPLOAD_TIMEOUT_MS); + try { + const response = await fetch(`${baseUrl}${path}`, { + ...init, + signal: controller.signal, + }); + // Return even non-2xx responses — let callers handle status codes. + return response; + } catch (error) { + lastError = error; + // Always try the next URL before giving up. + continue; + } finally { + clearTimeout(timeoutId); + } + } + + if (lastError instanceof Error && lastError.name === "AbortError") { + throw new Error( + `Upload timed out after ${UPLOAD_TIMEOUT_MS / 1000}s. Check API connectivity to ${API_BASE_URL}.`, + ); + } + const detail = lastError instanceof Error ? lastError.message : String(lastError); + throw new Error(`Upload failed while connecting to ${API_BASE_URL}: ${detail}`); +}; + +const uploadMultipartWithFallback = async ( + path: string, + body: FormData, + headers: Headers, +) => { + let lastError: unknown = null; + + for (const baseUrl of API_UPLOAD_BASE_URLS) { + const targetUrl = `${baseUrl}${path}`; + + try { + const result = await new Promise<{ + status: number; + responseText: string; + }>((resolve, reject) => { + const xhr = new XMLHttpRequest(); + let settled = false; + let watchdog: ReturnType | null = null; + + const finalize = ( + callback: () => void, + ) => { + if (settled) { + return; + } + settled = true; + if (watchdog) { + clearTimeout(watchdog); + watchdog = null; + } + xhr.onload = null; + xhr.onerror = null; + xhr.ontimeout = null; + xhr.onabort = null; + callback(); + }; + + xhr.open("POST", targetUrl); + xhr.timeout = UPLOAD_TIMEOUT_MS; + + headers.forEach((value, key) => { + if (key.toLowerCase() !== "content-type") { + xhr.setRequestHeader(key, value); + } + }); + + xhr.onload = () => + finalize(() => { + resolve({ + status: xhr.status, + responseText: xhr.responseText ?? "", + }); + }); + + xhr.onerror = () => + finalize(() => reject(new Error(`Upload network error for ${targetUrl}`))); + xhr.ontimeout = () => + finalize(() => + reject( + new Error( + `Upload timed out after ${UPLOAD_TIMEOUT_MS / 1000}s. Check API connectivity to ${baseUrl}.`, + ), + ), + ); + xhr.onabort = () => finalize(() => reject(new Error(`Upload aborted for ${targetUrl}`))); + + watchdog = setTimeout(() => { + try { + xhr.abort(); + } catch { + // Ignore abort errors; we still reject below. + } + finalize(() => + reject( + new Error( + `Upload watchdog timeout after ${UPLOAD_TIMEOUT_MS / 1000}s for ${baseUrl}.`, + ), + ), + ); + }, UPLOAD_TIMEOUT_MS + 1500); + + xhr.send(body); + }); + + return result; + } catch (error) { + lastError = error; + continue; + } + } + + const detail = lastError instanceof Error ? lastError.message : String(lastError); + throw new Error(`Upload failed while connecting to ${API_BASE_URL}: ${detail}`); +}; + +const guessMimeTypeFromFilename = (name: string, fallbackType: string) => { + const lower = name.toLowerCase(); + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; + if (lower.endsWith(".png")) return "image/png"; + if (lower.endsWith(".gif")) return "image/gif"; + if (lower.endsWith(".webp")) return "image/webp"; + if (lower.endsWith(".heic")) return "image/heic"; + if (lower.endsWith(".mov")) return "video/quicktime"; + if (lower.endsWith(".mp4")) return "video/mp4"; + if (lower.endsWith(".pdf")) return "application/pdf"; + return fallbackType || "application/octet-stream"; +}; + +const buildInlineMediaFallback = async (file: { + uri: string; + name: string; + type: string; +}) => { + const normalizedUri = normalizeUploadUri(file.uri); + if (!/^file:|^content:/i.test(normalizedUri)) { + throw new Error("Inline fallback requires a local file URI"); + } + + const info = await FileSystem.getInfoAsync(normalizedUri); + if (!info.exists) { + throw new Error("Inline fallback source file not found"); + } + + const fileSize = typeof (info as { size?: number }).size === "number" + ? (info as { size?: number }).size + : undefined; + + if (typeof fileSize === "number" && fileSize > MAX_INLINE_MEDIA_FALLBACK_BYTES) { + throw new Error( + `Media file too large for inline fallback (${Math.round(fileSize / (1024 * 1024))}MB).`, + ); + } + + const base64 = await FileSystem.readAsStringAsync(normalizedUri, { + encoding: FileSystem.EncodingType.Base64, + }); + + const mimeType = guessMimeTypeFromFilename(file.name, file.type); + return { + url: `data:${mimeType};base64,${base64}`, + filename: file.name, + contentType: mimeType, + size: fileSize, + }; +}; + +const toLocalAwareApiUrl = (input: string) => { + try { + const parsed = new URL(input); + const apiBase = new URL(API_BASE_URL); + const localHosts = new Set(["localhost", "127.0.0.1", "0.0.0.0"]); + if (!localHosts.has(parsed.hostname.toLowerCase())) { + return input; + } + + parsed.protocol = apiBase.protocol; + parsed.hostname = apiBase.hostname; + parsed.port = apiBase.port; + return parsed.toString(); + } catch { + return input; + } +}; + +export const checkApiConnection = async (): Promise => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), HEALTHCHECK_TIMEOUT_MS); + + try { + const response = await fetch(`${API_BASE_URL}/health`, { + method: "GET", + signal: controller.signal, + }); + if (!response.ok) { + throw new Error(`Health check failed (${response.status})`); + } + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + throw new Error( + `Cannot reach API (${API_BASE_URL}). Health check timed out after ${HEALTHCHECK_TIMEOUT_MS / 1000}s.`, + ); + } + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`Cannot reach API (${API_BASE_URL}): ${detail}`); + } finally { + clearTimeout(timeoutId); + } +}; + +export const resolveMediaUrl = (value?: string | null) => { + if (!value) { + return ""; + } + const trimmed = value.trim(); + if (!trimmed) { + return ""; + } + if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) { + return toLocalAwareApiUrl(trimmed); + } + + // Treat leading-slash paths as server-root references and return + // an absolute URL so consumers (Image, fetch, WebView) receive + // a valid absolute URL instead of a bare path like `/image/foo.svg`. + if (trimmed.startsWith("/")) { + return `${API_BASE_URL}${trimmed}`; + } + + const normalized = trimmed.startsWith("/") ? trimmed.slice(1) : trimmed; + if (normalized.startsWith("uploads/")) { + return `${API_BASE_URL}/${normalized}`; + } + + return trimmed; +}; + +const request = async ( + path: string, + init?: RequestInit, + options?: RequestOptions, +): Promise => { + const method = (init?.method ?? "GET").toUpperCase(); + const isGetRequest = method === "GET" && !init?.body; + const forceFresh = !!options?.forceFresh || isFreshReadWindowActive(); + const requestPath = + forceFresh && isGetRequest + ? `${path}${path.includes("?") ? "&" : "?"}_rf=${Date.now()}` + : path; + const inFlightKey = `${authToken ?? ""}::${path}`; + const timeoutMs = + typeof options?.timeoutMs === "number" + ? Math.max(0, options.timeoutMs) + : REQUEST_TIMEOUT_MS; + + if (isGetRequest && !forceFresh) { + const existing = inFlightGetRequests.get(inFlightKey); + if (existing) { + return existing as Promise; + } + } + + const execute = async (): Promise => { + const headers = new Headers(init?.headers as HeadersInit | undefined); + // Only set JSON content-type for requests with a body that is not FormData + const hasBody = !!init?.body; + const isFormData = typeof FormData !== "undefined" && init?.body instanceof FormData; + if (hasBody && !isFormData && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + if (authToken) { + headers.set("Authorization", `Bearer ${authToken}`); + } + if (isGetRequest) { + headers.set("Cache-Control", "no-cache"); + headers.set("Pragma", "no-cache"); + } + + const controller = new AbortController(); + const timeoutId = + timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : null; + + const requestBaseUrls = + API_FALLBACK_URL && API_FALLBACK_URL !== API_BASE_URL + ? [API_BASE_URL, API_FALLBACK_URL] + : [API_BASE_URL]; + + let response: Response | null = null; + let lastError: unknown = null; + + for (const baseUrl of requestBaseUrls) { + try { + response = await fetch(`${baseUrl}${requestPath}`, { + ...init, + headers, + signal: controller.signal, + }); + lastError = null; + break; + } catch (error) { + lastError = error; + continue; + } + } + + if (timeoutId) { + clearTimeout(timeoutId); + } + + if (!response) { + if (lastError instanceof Error && lastError.name === "AbortError") { + throw new Error( + `Request timed out after ${timeoutMs / 1000}s. Check API connectivity to ${API_BASE_URL}.`, + ); + } + const detail = + lastError instanceof Error ? lastError.message : String(lastError); + throw new Error(`Network error connecting to ${API_BASE_URL}: ${detail}`); + } + + const text = await response.text(); + + if (!response.ok) { + throw new Error(text || `Request failed (${response.status})`); + } + + if (!text) { + return (null as unknown) as T; + } + + try { + return JSON.parse(text) as T; + } catch { + throw new Error(`Invalid JSON response: ${text}`); + } + }; + + if (!isGetRequest || forceFresh) { + return execute(); + } + + const pending = execute().finally(() => { + inFlightGetRequests.delete(inFlightKey); + }); + setWithLimit( + inFlightGetRequests, + inFlightKey, + pending as Promise, + MAX_INFLIGHT_GET_ENTRIES, + ); + return pending; +}; + +const normalizeLinks = (value: unknown): string[] => { + if (Array.isArray(value)) { + return value.filter((item): item is string => typeof item === "string"); + } + if (value && typeof value === "object") { + return Object.values(value).filter( + (item): item is string => typeof item === "string", + ); + } + return []; +}; + +const normalizeUserIdList = (value: unknown): number[] => { + if (!Array.isArray(value)) { + return []; + } + + const toId = (entry: unknown) => { + if (typeof entry === "number" && Number.isFinite(entry)) { + return Math.trunc(entry); + } + if (entry && typeof entry === "object") { + const raw = (entry as { id?: unknown }).id; + if (typeof raw === "number" && Number.isFinite(raw)) { + return Math.trunc(raw); + } + if (typeof raw === "string" && raw.trim()) { + const parsed = Number(raw); + if (Number.isFinite(parsed)) { + return Math.trunc(parsed); + } + } + } + return null; + }; + + const ids = value + .map(toId) + .filter((id): id is number => typeof id === "number" && id > 0); + + return Array.from(new Set(ids)); +}; + +const normalizeUser = (user: ApiUserWire): ApiUser => { + const created = + (typeof user.created_on === "string" && user.created_on) || + (typeof user.creation_date === "string" && user.creation_date) || + ""; + + return { + ...user, + created_on: created, + links: normalizeLinks(user.links), + }; +}; + +const normalizeAuthResponse = (response: AuthResponse): AuthResponse => ({ + ...response, + user: normalizeUser(response.user as ApiUserWire), +}); + +export type FeedSort = "time" | "likes" | "new" | "recent" | "popular" | "hot"; + +export const getPostsFeed = (type: FeedSort, start = 0, count = 10) => + request(`/feed/posts?type=${type}&start=${start}&count=${count}`); + +export const getFollowingPostsFeed = ( + username: string, + start = 0, + count = 10, + sort: FeedSort = "recent", +) => + request( + `/feed/posts/following/${username}?start=${start}&count=${count}&sort=${sort}`, + ); + +export const getSavedPostsFeed = ( + username: string, + start = 0, + count = 10, + sort: FeedSort = "recent", +) => + request(`/feed/posts/saved/${username}?start=${start}&count=${count}&sort=${sort}`); + +export const registerUser = async (payload: AuthRegisterRequest) => { + const response = await request("/auth/register", { + method: "POST", + body: JSON.stringify(payload), + }); + return normalizeAuthResponse(response); +}; + +export const loginUser = async (payload: AuthLoginRequest) => { + const response = await request("/auth/login", { + method: "POST", + body: JSON.stringify(payload), + }); + return normalizeAuthResponse(response); +}; + +export const getMe = async () => { + const user = await request("/auth/me"); + const normalized = normalizeUser(user); + setUserCache(normalized); + return normalized; +}; + +export const getProjectsFeed = ( + type: FeedSort, + start = 0, + count = 10 +) => request(`/feed/projects?type=${type}&start=${start}&count=${count}`); + +export const getFollowingProjectsFeed = ( + username: string, + start = 0, + count = 10, + sort: FeedSort = "recent", +) => + request( + `/feed/projects/following/${username}?start=${start}&count=${count}&sort=${sort}`, + ); + +export const getSavedProjectsFeed = ( + username: string, + start = 0, + count = 10, + sort: FeedSort = "recent", +) => + request( + `/feed/projects/saved/${username}?start=${start}&count=${count}&sort=${sort}`, + ); + +export const getUserByUsername = async (username: string) => { + const user = await request(`/users/${username}`); + const normalized = normalizeUser(user); + setUserCache(normalized); + return normalized; +}; + +export const getUserById = async (userId: number) => { + const cached = isFreshReadWindowActive() ? null : getFreshUserCache(userId); + if (cached) { + return cached; + } + const wireUser = await request(`/users/id/${userId}`, undefined, { + forceFresh: isFreshReadWindowActive(), + }); + const user = normalizeUser(wireUser); + setUserCache(user); + return user; +}; + +export type ManagedMediaItem = { + filename: string; + url: string; +}; + +export const getMyManagedMedia = (username: string) => + request<{ items: ManagedMediaItem[] }>(`/users/${username}/media`); + +export const deleteMyManagedMedia = ( + username: string, + payload: { filenames?: string[]; deleteAll?: boolean }, +) => + request<{ message: string; removed: number }>(`/users/${username}/media`, { + method: "DELETE", + body: JSON.stringify(payload), + }); + +export const getAllUsers = async (start = 0, count = 50) => { + const users = await request(`/users?start=${start}&count=${count}`); + return users.map(normalizeUser); +}; + +export const getProjectById = async (projectId: number) => { + return request(`/projects/${projectId}`); +}; + +export const getProjectsByUserId = (userId: number) => + request(`/projects/by-user/${userId}`); + +export const getProjectsByBuilderId = (userId: number) => + request(`/projects/by-builder/${userId}`); + +export const getPostsByUserId = (userId: number) => + request(`/posts/by-user/${userId}`); + +export const getPostsByProjectId = (projectId: number) => + request(`/posts/by-project/${projectId}`); + +export const createPost = (payload: CreatePostRequest) => + request<{ message: string }>("/posts", { + method: "POST", + body: JSON.stringify(payload), + }); + +export const createProject = (payload: CreateProjectRequest) => + request<{ message: string }>("/projects", { + method: "POST", + body: JSON.stringify(payload), + }); + +export const updateProject = ( + projectId: number, + payload: Partial, +) => + request<{ message: string; project: ApiProject }>(`/projects/${projectId}`, { + method: "PUT", + body: JSON.stringify(payload), + }); + +export const deleteProject = (projectId: number) => + request<{ message: string }>(`/projects/${projectId}`, { + method: "DELETE", + }); + +export const getPostById = (postId: number) => + request(`/posts/${postId}`); + +export const likePost = (username: string, postId: number) => + request<{ message: string }>(`/posts/${username}/likes/${postId}`, { + method: "POST", + }); + +export const unlikePost = (username: string, postId: number) => + request<{ message: string }>(`/posts/${username}/unlikes/${postId}`, { + method: "POST", + }); + +export const isPostLiked = (username: string, postId: number) => + request<{ status: boolean }>(`/posts/does-like/${username}/${postId}`); + +export const getCommentsByPostId = (postId: number) => + request(`/comments/by-post/${postId}`); + +export const createCommentOnPost = (postId: number, payload: CreateCommentRequest) => + request<{ message: string }>(`/comments/for-post/${postId}`, { + method: "POST", + body: JSON.stringify(payload), + }); + +export const likeComment = (username: string, commentId: number) => + request<{ message: string }>(`/comments/${username}/likes/${commentId}`, { + method: "POST", + }); + +export const unlikeComment = (username: string, commentId: number) => + request<{ message: string }>(`/comments/${username}/unlikes/${commentId}`, { + method: "POST", + }); + +export const isCommentLiked = (username: string, commentId: number) => + request<{ status: boolean }>(`/comments/does-like/${username}/${commentId}`); + +export const getUsersFollowers = async (username: string) => { + const response = await request(`/users/${username}/followers`); + return normalizeUserIdList(response); +}; + +export const getUsersFollowing = async (username: string) => { + const response = await request(`/users/${username}/follows`); + return normalizeUserIdList(response); +}; + +export const getUsersFollowersUsernames = async (username: string) => { + const response = await request<{ followers?: string[] }>( + `/users/${username}/followers/usernames`, + ); + return response?.followers ?? []; +}; + +export const getUsersFollowingUsernames = async (username: string) => { + const response = await request<{ following?: string[] }>( + `/users/${username}/follows/usernames`, + ); + return response?.following ?? []; +}; + +export const getProjectFollowing = (username: string) => + request(`/projects/follows/${username}`); + +export const followUser = (username: string, target: string) => + request<{ message: string }>(`/users/${username}/follow/${target}`, { + method: "POST", + }); + +export const unfollowUser = (username: string, target: string) => + request<{ message: string }>(`/users/${username}/unfollow/${target}`, { + method: "POST", + }); + +export const followProject = (username: string, projectId: number) => + request<{ message: string }>(`/projects/user/${username}/follow/${projectId}`, { + method: "POST", + }); + +export const unfollowProject = async (username: string, projectId: number) => { + try { + return await request<{ message: string }>( + `/projects/user/${username}/unfollow/${projectId}`, + { + method: "POST", + }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : ""; + if (message.includes("User is not following this project")) { + return { message: "User is not following this project" }; + } + throw error; + } +}; + +export const likeProject = (username: string, projectId: number) => + request<{ message: string }>(`/projects/user/${username}/likes/${projectId}`, { + method: "POST", + }); + +export const unlikeProject = (username: string, projectId: number) => + request<{ message: string }>(`/projects/user/${username}/unlikes/${projectId}`, { + method: "POST", + }); + +export const isProjectLiked = (username: string, projectId: number) => + request<{ status: boolean }>(`/projects/does-like/${username}/${projectId}`); + +export const updateUser = async (username: string, payload: UpdateUserRequest) => { + const pictureValue = typeof payload.picture === "string" ? payload.picture.trim() : ""; + const timeoutMs = pictureValue.startsWith("data:") + ? LARGE_REQUEST_TIMEOUT_MS + : REQUEST_TIMEOUT_MS; + // Use POST /users/:username/update instead of PUT /users/:username + // because iOS CFNetwork intermittently drops PUT request bodies, + // causing silent 400 errors. POST bodies are delivered reliably. + const response = await request<{ message: string; user: ApiUserWire }>(`/users/${username}/update`, { + method: "POST", + body: JSON.stringify(payload), + }, { timeoutMs }); + const normalizedUser = normalizeUser(response.user); + setUserCache(normalizedUser); + return { + ...response, + user: normalizedUser, + }; +}; + +export const deleteUser = (username: string) => + request<{ message: string }>(`/users/${username}`, { + method: "DELETE", + }); + +export const getProjectBuilders = (projectId: number) => + request(`/projects/${projectId}/builders`); + +export const addProjectBuilder = (projectId: number, username: string) => + request<{ message: string }>(`/projects/${projectId}/builders/${username}`, { + method: "POST", + }); + +export const removeProjectBuilder = (projectId: number, username: string) => + request<{ message: string }>(`/projects/${projectId}/builders/${username}`, { + method: "DELETE", + }); + +export const updatePost = ( + postId: number, + payload: { + content?: string; + project?: number; + user?: number; + media?: string[]; + }, +) => + request<{ message: string; post: ApiPost }>(`/posts/${postId}`, { + method: "PUT", + body: JSON.stringify(payload), + }); + +export const deletePost = (postId: number) => + request<{ message: string }>(`/posts/${postId}`, { + method: "DELETE", + }); + +export const updateComment = ( + commentId: number, + payload: { content: string; media?: string[] }, +) => + request<{ message: string; comment: ApiComment }>(`/comments/${commentId}`, { + method: "PUT", + body: JSON.stringify(payload), + }); + +export const deleteComment = (commentId: number) => + request<{ message: string }>(`/comments/${commentId}`, { + method: "DELETE", + }); + +export const savePost = (username: string, postId: number) => + request<{ message: string }>(`/posts/${username}/save/${postId}`, { + method: "POST", + }); + +export const unsavePost = (username: string, postId: number) => + request<{ message: string }>(`/posts/${username}/unsave/${postId}`, { + method: "POST", + }); + +export const getSavedPosts = (username: string) => + request(`/posts/saved/${username}`); + +export const registerPushToken = (payload: { + token: string; + platform: string; +}) => + request<{ message: string }>("/notifications/push-token", { + method: "POST", + body: JSON.stringify(payload), + }); + +export const getNotifications = (start = 0, count = 50) => + request(`/notifications?start=${start}&count=${count}`); + +export const getNotificationCount = () => + request<{ count: number }>("/notifications/unread-count"); + +export const markNotificationRead = (notificationId: number) => + request<{ message: string }>(`/notifications/${notificationId}/read`, { + method: "POST", + }); + +export const deleteNotification = (notificationId: number) => + request<{ message: string }>(`/notifications/${notificationId}`, { + method: "DELETE", + }); + +export const clearNotifications = () => + request<{ message: string }>("/notifications", { + method: "DELETE", + }); + +export const getDirectChatPeers = async (username: string) => { + const response = await request<{ message?: string; peers?: string[] }>( + `/messages/${username}/peers`, + ); + return response?.peers ?? []; +}; + +export const getDirectMessageThreads = async ( + username: string, + start = 0, + count = 50, +) => { + const response = await request<{ + message?: string; + threads?: ApiDirectMessageThread[]; + }>(`/messages/${username}/threads?start=${start}&count=${count}`); + return response?.threads ?? []; +}; + +export const getDirectMessages = ( + username: string, + other: string, + start = 0, + count = 100, +) => + request( + `/messages/${username}/with/${other}?start=${start}&count=${count}`, + ); + +export const createDirectMessage = ( + username: string, + other: string, + content: string, +) => + request<{ message: string; direct_message: ApiDirectMessage }>( + `/messages/${username}/with/${other}`, + { + method: "POST", + body: JSON.stringify({ content }), + }, + ); + +const normalizeUploadUri = (uri: string) => { + const source = uri.trim(); + if (!source) { + return source; + } + if (/^file:\/\/\/var\/mobile\//i.test(source)) { + return encodeURI(source); + } + if (/^file:\/var\/mobile\//i.test(source)) { + const normalized = source.replace(/^file:\/+/, ""); + return encodeURI(`file:///${normalized}`); + } + if (/^[a-z][a-z0-9+.-]*:/i.test(source)) { + return /^file:/i.test(source) ? encodeURI(source) : source; + } + if (source.startsWith("//")) { + return `file:${source}`; + } + if (source.startsWith("/")) { + return `file://${source}`; + } + return source; +}; + +export const uploadMedia = async (file: { + uri: string; + name: string; + type: string; +}): Promise<{ + url: string; + filename: string; + contentType?: string; + size?: number; +}> => { + const normalizedUri = normalizeUploadUri(file.uri); + + const headers = new Headers(); + if (authToken) headers.set("Authorization", `Bearer ${authToken}`); + headers.set("Accept", "application/json"); + + // Retry a few times for transient network issues only. + const MAX_RETRIES = 3; + let lastError: unknown; + + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + const body = new FormData(); + (body as any).append("file", { + uri: normalizedUri, + name: file.name, + type: file.type, + }); + + try { + const response = await uploadMultipartWithFallback("/media/upload", body, headers); + + const text = response.responseText; + if (response.status < 200 || response.status >= 300) { + const detail = text || `Upload failed (${response.status})`; + const retryable = response.status >= 500 || response.status === 429; + const error = new Error(detail) as Error & { retryable?: boolean }; + error.retryable = retryable; + throw error; + } + if (!text) { + throw new Error("Empty upload response"); + } + const parsed = JSON.parse(text) as { + url: string; + filename: string; + contentType?: string; + size?: number; + }; + return { + ...parsed, + url: resolveMediaUrl(parsed.url), + }; + } catch (err) { + lastError = err; + const retryableError = + (err as { retryable?: boolean } | null)?.retryable === true || + isTransientUploadError(err); + + if (attempt < MAX_RETRIES && retryableError) { + await new Promise((r) => setTimeout(r, 250)); + continue; + } + + break; + } + } + + try { + return await buildInlineMediaFallback(file); + } catch (fallbackError) { + const primaryDetail = + lastError instanceof Error ? (lastError as Error).message : String(lastError); + const fallbackDetail = + fallbackError instanceof Error + ? fallbackError.message + : String(fallbackError); + throw new Error( + `Upload failed. Multipart: ${primaryDetail}. Fallback: ${fallbackDetail}`, + ); + } +}; diff --git a/frontend/services/mappers.ts b/frontend/services/mappers.ts new file mode 100644 index 0000000..d0ffdbf --- /dev/null +++ b/frontend/services/mappers.ts @@ -0,0 +1,45 @@ +import { ApiPost, ApiProject, ApiUser, UiPost, UiProject } from "@/constants/Types"; + +export const mapProjectToUi = ( + project: ApiProject, + contributors = 0, +): UiProject => { + return { + id: project.id, + ownerId: project.owner, + name: project.name, + summary: project.description ?? "", + about_md: project.about_md ?? "", + stage: project.status === 2 ? "launch" : project.status === 1 ? "beta" : "alpha", + likes: project.likes, + saves: project.saves ?? 0, + contributors, + tags: project.tags ?? [], + media: project.media ?? [], + updated_on: project.creation_date, + }; +}; + +export const mapPostToUi = ( + post: ApiPost, + user?: ApiUser | null, + project?: ApiProject | null +): UiPost => { + return { + id: post.id, + username: user?.username ?? `user-${post.user}`, + userPicture: user?.picture ?? undefined, + userId: post.user, + projectId: post.project, + projectName: project?.name ?? `Project ${post.project}`, + projectStage: + project?.status === 2 ? "launch" : project?.status === 1 ? "beta" : "alpha", + likes: post.likes, + saves: post.saves ?? 0, + comments: 0, + content: post.content, + media: post.media ?? [], + created_on: post.created_on, + tags: project?.tags ?? [], + }; +}; diff --git a/frontend/services/postEvents.ts b/frontend/services/postEvents.ts new file mode 100644 index 0000000..120904a --- /dev/null +++ b/frontend/services/postEvents.ts @@ -0,0 +1,46 @@ +type PostEvent = + | { type: "updated"; postId: number; content: string; media?: string[] } + | { type: "deleted"; postId: number } + | { + type: "stats"; + postId: number; + likes?: number; + comments?: number; + isLiked?: boolean; + }; + +type PostEventListener = (event: PostEvent) => void; + +const listeners = new Set(); + +export const subscribeToPostEvents = (listener: PostEventListener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +}; + +const emit = (event: PostEvent) => { + listeners.forEach((listener) => { + listener(event); + }); +}; + +export const emitPostUpdated = ( + postId: number, + content: string, + media?: string[], +) => { + emit({ type: "updated", postId, content, media }); +}; + +export const emitPostDeleted = (postId: number) => { + emit({ type: "deleted", postId }); +}; + +export const emitPostStats = ( + postId: number, + stats: { likes?: number; comments?: number; isLiked?: boolean }, +) => { + emit({ type: "stats", postId, ...stats }); +}; diff --git a/frontend/services/projectEvents.ts b/frontend/services/projectEvents.ts new file mode 100644 index 0000000..87682ce --- /dev/null +++ b/frontend/services/projectEvents.ts @@ -0,0 +1,75 @@ +import { UiProject } from "@/constants/Types"; + +type ProjectEvent = + | { type: "updated"; projectId: number; patch: Partial } + | { type: "deleted"; projectId: number } + | { + type: "stats"; + projectId: number; + likes?: number; + saves?: number; + isLiked?: boolean; + }; + +type ProjectEventListener = (event: ProjectEvent) => void; + +const listeners = new Set(); + +export const subscribeToProjectEvents = (listener: ProjectEventListener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +}; + +const emit = (event: ProjectEvent) => { + listeners.forEach((listener) => { + listener(event); + }); +}; + +export const emitProjectUpdated = ( + projectId: number, + patch: Partial, +) => { + emit({ type: "updated", projectId, patch }); +}; + +export const emitProjectDeleted = (projectId: number) => { + emit({ type: "deleted", projectId }); +}; + +export const emitProjectStats = ( + projectId: number, + stats: { likes?: number; saves?: number; isLiked?: boolean }, +) => { + emit({ type: "stats", projectId, ...stats }); +}; + +export const applyProjectEvent = ( + projects: UiProject[], + event: ProjectEvent, +) => { + if (event.type === "deleted") { + return projects.filter((project) => project.id !== event.projectId); + } + if (event.type === "updated") { + return projects.map((project) => + project.id === event.projectId + ? { ...project, ...event.patch } + : project, + ); + } + return projects.map((project) => { + if (project.id !== event.projectId) { + return project; + } + return { + ...project, + likes: + typeof event.likes === "number" ? event.likes : project.likes, + saves: + typeof event.saves === "number" ? event.saves : project.saves, + }; + }); +}; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index c9434f7..8466a87 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,18 +1,22 @@ { - "extends": "expo/tsconfig.base", "compilerOptions": { - "strict": true, + "ignoreDeprecations": "6.0", + "baseUrl": ".", "paths": { - "@/*": [ - "./*" - ] + "@/*": ["./*"] }, - "jsx": "preserve" + "moduleResolution": "bundler", + "types": ["jest", "react", "react-native"] }, + "extends": "expo/tsconfig.base", "include": [ "**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts" + ], + "exclude": [ + "node_modules", + "**/node_modules/*" ] } diff --git a/logs/crash-full-20260219-134335.log b/logs/crash-full-20260219-134335.log new file mode 100644 index 0000000..e69de29 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..de2878d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "DevBits", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/run-all.ps1 b/run-all.ps1 new file mode 100644 index 0000000..de21d60 --- /dev/null +++ b/run-all.ps1 @@ -0,0 +1,263 @@ +param( + [ValidateSet("live", "local-clean", "local-existing")] + [string]$Mode, + [switch]$UseLocalBackend, + [string]$ApiUrl = "https://devbits.ddns.net", + [switch]$KeepBackend, + [switch]$Rebuild, + [switch]$NoStart, + [switch]$NoPause +) + +$ErrorActionPreference = "Stop" + +if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + $arguments = "& '" + $myinvocation.mycommand.definition + "'" + Start-Process powershell -Verb runAs -ArgumentList $arguments + exit +} + +$root = Split-Path -Parent $MyInvocation.MyCommand.Path + +function Resolve-RunMode { + if ($Mode) { + return $Mode + } + + if ($UseLocalBackend -and $KeepBackend) { + return "local-existing" + } + if ($UseLocalBackend) { + return "local-clean" + } + + Write-Host "Select backend mode:" -ForegroundColor Cyan + Write-Host " 1) live - connect frontend to deployed backend ($ApiUrl)" + Write-Host " 2) local-clean - reset local docker backend to blank-slate then run" + Write-Host " 3) local-existing- use existing local docker backend without reset" + + $selection = Read-Host "Enter 1, 2, or 3 (default 1)" + switch ($selection) { + "2" { return "local-clean" } + "3" { return "local-existing" } + default { return "live" } + } +} + +function Test-BackendHealthy { + try { + $response = Invoke-WebRequest -Uri "http://localhost/health" -UseBasicParsing -TimeoutSec 5 + if ($response.StatusCode -eq 200 -and $response.Content -match "API is running") { + return $true + } + } + catch { + # Will retry + } + return $false +} + +function Resolve-AndroidSdkPath { + $candidates = @() + + if ($env:ANDROID_HOME) { + $candidates += $env:ANDROID_HOME + } + if ($env:ANDROID_SDK_ROOT) { + $candidates += $env:ANDROID_SDK_ROOT + } + + $candidates += "C:\Users\$env:USERNAME\AppData\Local\Android\Sdk" + $candidates += "C:\Users\$env:USERNAME\AppData\Local\Android\sdk" + $candidates += "C:\Android\Sdk" + $candidates += "C:\Android\sdk" + + $wingetPkgRoot = Join-Path $env:LOCALAPPDATA "Microsoft\WinGet\Packages" + if (Test-Path $wingetPkgRoot) { + $platformToolPackages = Get-ChildItem $wingetPkgRoot -Directory -Filter "Google.PlatformTools*" -ErrorAction SilentlyContinue + foreach ($pkg in $platformToolPackages) { + $candidates += $pkg.FullName + } + } + + $uniqueCandidates = $candidates | Where-Object { $_ } | Select-Object -Unique + + foreach ($path in $uniqueCandidates) { + $platformToolsPath = Join-Path $path "platform-tools" + $emulatorPath = Join-Path $path "emulator\emulator.exe" + if ((Test-Path (Join-Path $platformToolsPath "adb.exe")) -and (Test-Path $emulatorPath)) { + return $path + } + } + + foreach ($path in $uniqueCandidates) { + $platformToolsPath = Join-Path $path "platform-tools" + if (Test-Path (Join-Path $platformToolsPath "adb.exe")) { + return $path + } + if (Test-Path (Join-Path $path "adb.exe")) { + return (Split-Path -Parent $path) + } + } + + return $null +} + +function Add-ToPathIfMissing([string]$pathToAdd) { + if ([string]::IsNullOrWhiteSpace($pathToAdd)) { + return + } + $currentPath = $env:Path -split ';' + if ($currentPath -contains $pathToAdd) { + return + } + $env:Path = "$pathToAdd;$env:Path" +} + +function Resolve-AndroidStudioPath { + $candidates = @( + "C:\Program Files\Android\Android Studio\bin\studio64.exe", + "C:\Program Files (x86)\Android\Android Studio\bin\studio64.exe", + "C:\Users\$env:USERNAME\AppData\Local\Android\Android Studio\bin\studio64.exe" + ) + + foreach ($path in $candidates) { + if (Test-Path $path) { + return $path + } + } + + return $null +} + +function Try-StartAndroidEmulator([string]$sdkPath) { + if ([string]::IsNullOrWhiteSpace($sdkPath)) { + return + } + + $emulatorExe = Join-Path $sdkPath "emulator\emulator.exe" + if (-not (Test-Path $emulatorExe)) { + Write-Warning "Android emulator not installed in SDK. Open Android Studio > SDK Manager and install Emulator + system image." + return + } + + $avdList = & $emulatorExe -list-avds 2>$null + if (-not $avdList -or $avdList.Count -eq 0) { + Write-Warning "No Android Virtual Device (AVD) found. Open Android Studio > Device Manager and create one." + return + } + + $selectedAvd = $avdList[0] + if ($selectedAvd) { + Write-Host "Starting Android emulator: $selectedAvd" -ForegroundColor Yellow + Start-Process $emulatorExe -ArgumentList "-avd", $selectedAvd + } +} + +$androidSdkPath = Resolve-AndroidSdkPath +$androidStudioPath = Resolve-AndroidStudioPath +if ($androidSdkPath) { + $env:ANDROID_HOME = $androidSdkPath + $env:ANDROID_SDK_ROOT = $androidSdkPath + + $platformTools = Join-Path $androidSdkPath "platform-tools" + if (Test-Path $platformTools) { + Add-ToPathIfMissing $platformTools + } + + $emulatorPath = Join-Path $androidSdkPath "emulator" + if (Test-Path $emulatorPath) { + Add-ToPathIfMissing $emulatorPath + } + + Write-Host "Android SDK: $androidSdkPath" -ForegroundColor Green +} +else { + Write-Warning "Android SDK not found." + Write-Warning "Run Android Studio once to complete SDK setup, then rerun this script." + if ($androidStudioPath) { + Write-Host "Launching Android Studio setup wizard..." -ForegroundColor Yellow + Start-Process $androidStudioPath + } + else { + Write-Warning "Android Studio missing. Install with: winget install --id Google.AndroidStudio --exact" + } +} + +function Wait-ForLocalBackendHealth { + Write-Host "Waiting for local backend to become healthy..." + $maxRetries = 15 + $retryInterval = 2 + $healthy = $false + for ($i = 0; $i -lt $maxRetries; $i++) { + if (Test-BackendHealthy) { + $healthy = $true + break + } + Start-Sleep -Seconds $retryInterval + } + + if ($healthy) { + Write-Host "Local backend is healthy at http://localhost/health" -ForegroundColor Green + } + else { + Write-Warning "Local backend did not become healthy in time. Check logs with 'docker compose -f backend/docker-compose.yml logs -f backend'." + } +} + +$runMode = Resolve-RunMode + +if ($runMode -eq "local-clean") { + Write-Host "Running in local-clean mode (fresh local backend)." -ForegroundColor Yellow + & "$root\backend\scripts\reset-deployment-db.ps1" + Wait-ForLocalBackendHealth +} +elseif ($runMode -eq "local-existing") { + Write-Host "Running in local-existing mode (no reset)." -ForegroundColor Yellow + Push-Location "$root\backend" + try { + $dockerArgs = "up", "-d" + if ($Rebuild) { + $dockerArgs += "--build" + } + docker compose $dockerArgs + } + finally { + Pop-Location + } + Wait-ForLocalBackendHealth +} +else { + Write-Host "Running in live mode (safe: no local DB reset)." -ForegroundColor Green + Write-Host "Using deployed backend API: $ApiUrl" -ForegroundColor Green +} + +if (-not $NoStart) { + $frontendApiUrl = if ($runMode -eq "live") { $ApiUrl } else { "http://localhost" } + $frontendCommand = "`$env:EXPO_PUBLIC_API_URL='$frontendApiUrl'; `$env:EXPO_PUBLIC_API_FALLBACK_URL='$frontendApiUrl'; npm run frontend" + Start-Process powershell -WorkingDirectory "$root\frontend" -ArgumentList "-NoExit", "-Command", $frontendCommand + + if ($androidSdkPath) { + Try-StartAndroidEmulator $androidSdkPath + } +} + +$liveBackendState = "unavailable" +try { + $composeFile = Join-Path $root "backend\docker-compose.yml" + $statusOutput = docker compose -f $composeFile ps backend 2>$null + if ($LASTEXITCODE -eq 0) { + $liveBackendState = if (($statusOutput | Out-String) -match "Up") { "running" } else { "not running" } + } +} +catch {} + +Write-Host "" +Write-Host "===== Summary =====" -ForegroundColor Cyan +Write-Host "Action: Run-all workflow executed in mode '$runMode'" +Write-Host "Updated: Frontend startup and selected backend workflow applied" +Write-Host "Live backend: $liveBackendState" + +if (-not $NoPause -and [Environment]::UserInteractive -and $Host.Name -eq "ConsoleHost") { + Read-Host "Press Enter to close" +} diff --git a/run-all.sh b/run-all.sh new file mode 100755 index 0000000..0e308d4 --- /dev/null +++ b/run-all.sh @@ -0,0 +1,136 @@ +#!/bin/bash + +# Exit immediately if a command exits with a non-zero status +# (Equivalent to $ErrorActionPreference = "Stop") +set -e + +# Get the directory where the script is located +# (Equivalent to Split-Path -Parent $MyInvocation.MyCommand.Path) +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +API_URL="${API_URL:-https://devbits.ddns.net}" +MODE="" +REBUILD=false + +if [ "$EUID" -ne 0 ]; then + echo "Please run as root" + sudo "$0" "$@" + exit +fi + +while [[ $# -gt 0 ]]; do + case "$1" in + --mode) + MODE="$2" + shift 2 + ;; + --local-backend) + MODE="local-clean" + shift + ;; + --api-url) + API_URL="$2" + shift 2 + ;; + --rebuild) + REBUILD=true + shift + ;; + *) + echo "Unknown option: $1" + echo "Usage: ./run-all.sh [--mode live|local-clean|local-existing] [--api-url ] [--rebuild]" + exit 1 + ;; + esac +done + +resolve_mode() { + if [[ -n "$MODE" ]]; then + echo "$MODE" + return + fi + + echo "Select backend mode:" + echo " 1) live - connect frontend to deployed backend ($API_URL)" + echo " 2) local-clean - reset local docker backend to blank-slate then run" + echo " 3) local-existing - use existing local docker backend without reset" + read -r -p "Enter 1, 2, or 3 (default 1): " selection + + case "$selection" in + 2) echo "local-clean" ;; + 3) echo "local-existing" ;; + *) echo "live" ;; + esac +} + +wait_for_local_health() { + local retries=15 + local sleep_seconds=2 + for ((i=1; i<=retries; i++)); do + if curl -fsS "http://localhost/health" >/dev/null 2>&1; then + echo "Local backend is healthy at http://localhost/health" + return + fi + sleep "$sleep_seconds" + done + + echo "Warning: local backend did not become healthy in time." +} + +# Start the Backend +# We use 'cd' inside a subshell () to keep the paths clean +RUN_MODE="$(resolve_mode)" + +case "$RUN_MODE" in + local-clean) + API_URL="http://localhost" + echo "Running in local-clean mode (fresh local backend)." + bash "$ROOT/backend/scripts/reset-deployment-db.sh" + wait_for_local_health + ;; + local-existing) + API_URL="http://localhost" + echo "Running in local-existing mode (no reset)." + pushd "$ROOT/backend" >/dev/null + if [[ "$REBUILD" == true ]]; then + docker compose up -d --build + else + docker compose up -d + fi + popd >/dev/null + wait_for_local_health + ;; + live) + echo "Running in live mode (safe: no local DB reset)." + echo "Using deployed backend API: $API_URL" + ;; + *) + echo "Invalid mode: $RUN_MODE" + echo "Use --mode live|local-clean|local-existing" + exit 1 + ;; +esac + +# Start the Frontend +echo "Starting frontend..." +( + cd "$ROOT/frontend" + EXPO_PUBLIC_API_URL="$API_URL" EXPO_PUBLIC_API_FALLBACK_URL="$API_URL" npm run frontend +) & + +# Keep the script running until all background processes finish +wait + +live_backend_state="unavailable" +if backend_status_output="$(docker compose -f "$ROOT/backend/docker-compose.yml" ps backend 2>/dev/null)"; then + if echo "$backend_status_output" | grep -q "Up"; then + live_backend_state="running" + else + live_backend_state="not running" + fi +fi + +echo +echo "===== Summary =====" +echo "Action: Run-all workflow executed in mode '$RUN_MODE'" +echo "Updated: Frontend startup and selected backend workflow applied" +echo "Live backend: $live_backend_state" diff --git a/scripts/install-adb.ps1 b/scripts/install-adb.ps1 new file mode 100644 index 0000000..ff03c63 --- /dev/null +++ b/scripts/install-adb.ps1 @@ -0,0 +1,78 @@ +<# +install-adb.ps1 + +Downloads Android Platform Tools (adb) and updates the current PowerShell session PATH. +Usage: run from an Administrator PowerShell: + .\scripts\install-adb.ps1 + +This script does NOT permanently modify user PATH. To persist, follow the printed setx command. +#> +param( + [string]$Url = 'https://dl.google.com/android/repository/platform-tools-latest-windows.zip', + [switch]$NoPause +) + +if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + $arguments = "& '" + $myinvocation.mycommand.definition + "'" + Start-Process powershell -Verb runAs -ArgumentList $arguments + exit +} + +Write-Output "Downloading Android Platform-Tools from $Url" +$dest = Join-Path $PSScriptRoot 'platform-tools.zip' +try { + Invoke-WebRequest -Uri $Url -OutFile $dest -UseBasicParsing -ErrorAction Stop +} +catch { + Write-Error "Failed to download platform-tools: $_" + exit 1 +} + +$extractDir = Join-Path $PSScriptRoot 'platform-tools' +if (Test-Path $extractDir) { Remove-Item -Recurse -Force $extractDir } + +Write-Output "Extracting to $PSScriptRoot" +try { + Expand-Archive -Path $dest -DestinationPath $PSScriptRoot -Force +} +catch { + Write-Error "Failed to extract: $_" + exit 1 +} + +# platform-tools folder is created next to script; locate it +$pt = Join-Path $PSScriptRoot 'platform-tools' +if (-not (Test-Path $pt)) { + # some zips contain a top-level folder named 'platform-tools' already + $pt = Get-ChildItem -Path $PSScriptRoot -Directory | Where-Object { $_.Name -like 'platform-tools*' } | Select-Object -First 1 + if ($pt) { $pt = $pt.FullName } else { Write-Error "platform-tools folder not found after extraction"; exit 1 } +} + +# Add to current session PATH +$env:Path = "$pt;$env:Path" + +Write-Output "adb installed at: $pt" +Write-Output "Current session PATH updated. Verify with: adb version" +Write-Output "To persist PATH for your user, run (adjust carefully):" +Write-Output " setx PATH \"$pt; %PATH%\"" +Write-Output "Done." + +$liveBackendState = "unavailable" +try { + $composeFile = Join-Path (Resolve-Path (Join-Path $PSScriptRoot "..")) "backend\docker-compose.yml" + $statusOutput = docker compose -f $composeFile ps backend 2>$null + if ($LASTEXITCODE -eq 0) { + $liveBackendState = if (($statusOutput | Out-String) -match "Up") { "running" } else { "not running" } + } +} +catch {} + +Write-Output "" +Write-Output "===== Summary =====" +Write-Output "Action: Android Platform Tools install/update executed" +Write-Output "Updated: adb binaries extracted and PATH updated for current session" +Write-Output "Live backend: $liveBackendState" + +if (-not $NoPause -and [Environment]::UserInteractive -and $Host.Name -eq "ConsoleHost") { + Read-Host "Press Enter to close" +} diff --git a/scripts/readme.md b/scripts/readme.md new file mode 100644 index 0000000..7392c4f --- /dev/null +++ b/scripts/readme.md @@ -0,0 +1,122 @@ +# React Native ADB Logging Guide + +Guide for connecting to an Android device/emulator and capturing logs for debugging React Native apps. + +--- + +## 1. Connect to Device/Emulator + +```cmd +adb connect 127.0.0.1:7555 +adb devices +``` + +**Command breakdown:** + +- `adb connect ` – Connects to a device over TCP/IP +- `adb devices` – Lists all connected devices/emulators + +**Expected output:** + +``` +List of devices attached +127.0.0.1:7555 device +``` + +--- + +## 2. Capture All Logs + +Capture complete system logs with timestamps: + +```cmd +adb -s 127.0.0.1:7555 logcat -v time > C:\Temp\crash-full.log +``` + +**Command breakdown:** + +- `-s ` – Target a specific device +- `logcat` – Capture Android system logs +- `-v time` – Add timestamps to each log line +- `> ` – Redirect output to a file + +**Stop logging:** Press `Ctrl+C` when done + +--- + +## 3. Capture React Native-Specific Logs + +Focus only on React Native logs and critical errors: + +```cmd +adb -s 127.0.0.1:7555 logcat -v time ReactNative:V ReactNativeJS:V AndroidRuntime:E *:S > C:\Temp\rn-js.log +``` + +**Command breakdown:** + +- `ReactNative:V` – Verbose logs for React Native tag +- `ReactNativeJS:V` – Verbose logs for React Native JS thread +- `AndroidRuntime:E` – Errors only from Android runtime +- `*:S` – Silence all other tags (reduces noise) + +**Stop logging:** Press `Ctrl+C` when done + +--- + +## Quick Reference + +| Command | Purpose | +| ---------------------------- | -------------------------- | +| `adb devices` | List connected devices | +| `adb connect 127.0.0.1:7555` | Connect to emulator/device | +| `adb logcat -v time` | View logs with timestamps | +| `Ctrl+C` | Stop log capture | + +--- + +## Common Issues + +**Command fails with "test is not recognized"** + +- Don't use `test` command on Windows (it's Linux/macOS only) + +**Command fails with extra text after redirect** + +- Don't add extra characters after `>` (e.g., avoid `... > file.log ttt`) +- Windows treats everything after `>` as a filename + +**Logs directory doesn't exist** + +- Create `C:\Temp\` or use an existing folder +- Or modify the path: `> C:\Users\\Documents\crash-full.log` + +--- + +## Tips for Debugging + +1. **Reproduce the crash** – Run the steps that cause the issue +2. **Capture logs** – Use the appropriate command above +3. **Stop logging** – Press `Ctrl+C` once issue appears +4. **Review logs** – Open the `.log` file to find the error +5. **Filter further** – Add more tags as needed (e.g., `MyApp:V`) + +--- + +## Example Workflow + +```cmd +# 1. Connect +adb connect 127.0.0.1:7555 +adb devices + +# 2. Start capturing React Native logs +adb -s 127.0.0.1:7555 logcat -v time ReactNative:V ReactNativeJS:V AndroidRuntime:E *:S > C:\Temp\rn-js.log + +# 3. Reproduce your crash in the app... + +# 4. Stop logging (Ctrl+C) + +# 5. Review C:\Temp\rn-js.log for errors +``` + +---