Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
e98ea06
feat(backend): add boost/quote support to posts
nekochanfood Jun 8, 2026
c6e9b68
feat(backend): auto-detect post reference from URL in content
nekochanfood Jun 8, 2026
f453c59
fix(backend): make post URL reference detection domain-agnostic
nekochanfood Jun 8, 2026
61ba733
feat(frontend): add prototype of boost button and reorder buttons
nekochanfood Jun 8, 2026
dba2fff
feat(backend): add boost/quote support to posts
nekochanfood Jun 8, 2026
5376a55
feat(backend): auto-detect post reference from URL in content
nekochanfood Jun 8, 2026
7fe4315
fix(backend): make post URL reference detection domain-agnostic
nekochanfood Jun 8, 2026
46ab31c
feat(frontend): add prototype of boost button and reorder buttons
nekochanfood Jun 8, 2026
305ec01
feat(frontend): display referenced post as embedded card in PostCard
nekochanfood Jun 8, 2026
c79f05a
merge: resolve conflict in PostCard.tsx with origin/feat/boost
nekochanfood Jun 8, 2026
e2e383d
feat(frontend): add boost/quote dropdown menu to PostCard action buttons
nekochanfood Jun 8, 2026
2e317e8
feat(frontend): add indicator prop to PostCard for top-of-card annota…
nekochanfood Jun 8, 2026
7b6fd09
feat(frontend): make embedded reference card clickable to navigate to…
nekochanfood Jun 8, 2026
e0ab19a
feat(frontend): implement pure boost action and boosted post indicator
nekochanfood Jun 9, 2026
b552622
fix(frontend): replace full-card overlay with granular click targets …
nekochanfood Jun 9, 2026
b682525
Merge branch 'development' into feat/boost
nekochanfood Jun 9, 2026
76b330d
merge: resolve PostCard avatar conflict (keep isEmbedded + nullish co…
nekochanfood Jun 9, 2026
195aacd
Merge branch 'development' into feat/boost
nekochanfood Jun 9, 2026
261ca45
feat(frontend): render MFM in PostCard indicator text
nekochanfood Jun 9, 2026
649b22a
feat(frontend): add quote post functionality via Boost menu
nekochanfood Jun 9, 2026
1be2074
fix review feedback for boost and quote flow
Copilot Jun 10, 2026
5df61c6
feat(frontend): add timestamp and menu to boost indicator
nekochanfood Jun 10, 2026
1bbffad
feat(frontend): align indicator icon and text with avatar/username co…
nekochanfood Jun 10, 2026
df254c1
feat(frontend): show placeholder for deleted boosted/quoted posts
nekochanfood Jun 10, 2026
354a119
merge: resolve boost indicator conflicts (keep deleted post placeholder)
nekochanfood Jun 10, 2026
66c89a2
Merge branch 'development' into feat/boost
nekochanfood Jun 10, 2026
dd13d4d
Merge branch 'development' into feat/boost
nekochanfood Jun 10, 2026
d70b18f
perf(docker): reduce backend image size from 800MB to 47MB
nekochanfood Jun 10, 2026
bd3c36f
Revert "perf(docker): reduce backend image size from 800MB to 47MB"
nekochanfood Jun 10, 2026
f247760
fix(frontend): fix deleted quoted post display layout
nekochanfood Jun 10, 2026
82345dd
fix(i18n): show "Copy Post ID" label on deleted post menu
nekochanfood Jun 10, 2026
4f6d22c
Merge branch 'development' into feat/boost
nekochanfood Jun 11, 2026
5614303
fix(frontend): copy boost data instead of referenced post data in ind…
nekochanfood Jun 11, 2026
6a4a874
fix(frontend): remove unnecessary bottom margins from embedded PostCard
nekochanfood Jun 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/backend/db/migrations/20260608_add_post_boosts.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
DROP INDEX IF EXISTS idx_posts_unique_pure_boost;
DROP INDEX IF EXISTS idx_posts_reference;
ALTER TABLE posts DROP CONSTRAINT IF EXISTS posts_reference_not_self;
ALTER TABLE posts DROP COLUMN IF EXISTS reference_id;
18 changes: 18 additions & 0 deletions apps/backend/db/migrations/20260608_add_post_boosts.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
-- Add reference_id column for boosts/quotes
ALTER TABLE posts
ADD COLUMN reference_id UUID REFERENCES posts(id) ON DELETE SET NULL;

-- Prevent self-reference
ALTER TABLE posts
ADD CONSTRAINT posts_reference_not_self
CHECK (reference_id IS NULL OR reference_id <> id);

-- Index for counting boosts of a post
CREATE INDEX idx_posts_reference
ON posts(reference_id)
WHERE reference_id IS NOT NULL AND deleted_at IS NULL;

-- Prevent duplicate pure boosts (same user boosting same post with no content)
CREATE UNIQUE INDEX idx_posts_unique_pure_boost
ON posts(user_id, reference_id)
WHERE reference_id IS NOT NULL AND content = '' AND deleted_at IS NULL;
24 changes: 20 additions & 4 deletions apps/backend/db/queries.sql
Original file line number Diff line number Diff line change
Expand Up @@ -225,9 +225,9 @@ DELETE FROM users
WHERE id = $1;

-- name: CreatePost :one
INSERT INTO posts (user_id, content, parent_id, root_id)
VALUES ($1, $2, sqlc.narg('parent_id'), sqlc.narg('root_id'))
RETURNING id, user_id, content, parent_id, root_id, created_at, deleted_at;
INSERT INTO posts (user_id, content, parent_id, root_id, reference_id)
VALUES ($1, $2, sqlc.narg('parent_id'), sqlc.narg('root_id'), sqlc.narg('reference_id'))
RETURNING id, user_id, content, parent_id, root_id, reference_id, created_at, deleted_at;

-- name: GetPostWithAuthorByID :one
SELECT
Expand All @@ -236,6 +236,7 @@ SELECT
p.content,
p.parent_id,
p.root_id,
p.reference_id,
p.created_at,
p.deleted_at,
u.username,
Expand All @@ -255,7 +256,7 @@ FROM posts
WHERE id = $1;

-- name: GetPostThreadInfoByID :one
SELECT id, parent_id, root_id, deleted_at
SELECT id, parent_id, root_id, reference_id, deleted_at
FROM posts
WHERE id = $1;

Expand All @@ -266,6 +267,7 @@ SELECT
p.content,
p.parent_id,
p.root_id,
p.reference_id,
p.created_at,
p.deleted_at,
u.username,
Expand Down Expand Up @@ -310,6 +312,7 @@ SELECT
child.content,
child.parent_id,
child.root_id,
child.reference_id,
child.created_at,
child.deleted_at,
child.username,
Expand All @@ -327,6 +330,7 @@ JOIN LATERAL (
p.content,
p.parent_id,
p.root_id,
p.reference_id,
p.created_at,
p.deleted_at,
u.username,
Expand Down Expand Up @@ -478,6 +482,7 @@ SELECT
p.content,
p.parent_id,
p.root_id,
p.reference_id,
p.created_at,
p.deleted_at,
u.username,
Expand Down Expand Up @@ -505,6 +510,7 @@ SELECT
p.content,
p.parent_id,
p.root_id,
p.reference_id,
p.created_at,
p.deleted_at,
u.username,
Expand Down Expand Up @@ -576,6 +582,7 @@ SELECT
p.content,
p.parent_id,
p.root_id,
p.reference_id,
p.created_at,
p.deleted_at,
u.username,
Expand Down Expand Up @@ -1603,3 +1610,12 @@ LIMIT $1 OFFSET $2;

-- name: CountCustomEmojis :one
SELECT COUNT(*) FROM custom_emojis;

-- ==================== Post Boosts ====================

-- name: CountBoostsByPostIDs :many
SELECT reference_id, COUNT(*)::int AS boost_count
FROM posts
WHERE reference_id = ANY($1::uuid[])
AND deleted_at IS NULL
GROUP BY reference_id;
6 changes: 5 additions & 1 deletion apps/backend/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -86,14 +86,16 @@ CREATE TABLE IF NOT EXISTS posts (
content TEXT NOT NULL,
parent_id UUID REFERENCES posts(id) ON DELETE SET NULL,
root_id UUID REFERENCES posts(id) ON DELETE SET NULL,
reference_id UUID REFERENCES posts(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL,
visibility TEXT NOT NULL DEFAULT 'public',
deleted_by UUID REFERENCES users(id) ON DELETE SET NULL,
deletion_reason TEXT,
CHECK (visibility IN ('public', 'hidden', 'deleted')),
CHECK (parent_id IS NULL OR parent_id <> id),
CHECK (root_id IS NULL OR root_id <> id)
CHECK (root_id IS NULL OR root_id <> id),
CHECK (reference_id IS NULL OR reference_id <> id)
);

-- Uploaded media (images and videos). Images stored as WebP, videos as MP4.
Expand Down Expand Up @@ -154,6 +156,8 @@ CREATE INDEX IF NOT EXISTS idx_posts_user_created ON posts (user_id, created_at
CREATE INDEX IF NOT EXISTS idx_posts_parent ON posts(parent_id) WHERE parent_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_posts_parent_created ON posts(parent_id, created_at ASC, id ASC) WHERE parent_id IS NOT NULL AND deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_posts_root ON posts(root_id) WHERE root_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_posts_reference ON posts(reference_id) WHERE reference_id IS NOT NULL AND deleted_at IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_posts_unique_pure_boost ON posts(user_id, reference_id) WHERE reference_id IS NOT NULL AND content = '' AND deleted_at IS NULL;

CREATE TABLE IF NOT EXISTS post_mentions (
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
Expand Down
29 changes: 28 additions & 1 deletion apps/backend/internal/service/mentions.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package service

import "regexp"
import (
"regexp"

"github.com/google/uuid"
)

// MaxMentionsPerPost caps the number of usernames extracted from a single post body.
// Mentions beyond this limit are silently dropped to prevent abuse.
Expand All @@ -11,6 +15,29 @@ const MaxMentionsPerPost = 50
// alphanumeric plus underscore, 3 to 32 characters.
var mentionPattern = regexp.MustCompile(`(^|[^a-zA-Z0-9_])@([a-zA-Z0-9_]{3,32})`)

// postURLPattern matches any http(s) URL with a /posts/{uuid} path.
var postURLPattern = regexp.MustCompile(
`https?://[^\s/]+(?:/[^\s/]+)*/posts/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})`,
)

// ExtractPostReference extracts the first post UUID referenced by a URL in content.
// It matches any http(s) URL containing /posts/{uuid} regardless of domain,
// so it works when frontend and backend are on different hosts.
func ExtractPostReference(content string) *uuid.UUID {
if content == "" {
return nil
}
m := postURLPattern.FindStringSubmatch(content)
if m == nil {
return nil
}
id, err := uuid.Parse(m[1])
if err != nil {
return nil
}
return &id
}

// ExtractMentions returns up to cap unique usernames mentioned in content.
// The order of the returned slice matches the first-occurrence order in content.
// Comparison is case-sensitive — ciel usernames are stored case-sensitively.
Expand Down
81 changes: 81 additions & 0 deletions apps/backend/internal/service/mentions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"reflect"
"strings"
"testing"

"github.com/google/uuid"
)

func TestExtractMentions(t *testing.T) {
Expand Down Expand Up @@ -131,6 +133,85 @@ func TestExtractMentions(t *testing.T) {
}
}

func TestExtractPostReference(t *testing.T) {
t.Parallel()

validID := uuid.MustParse("550e8400-e29b-41d4-a716-446655440000")

tests := []struct {
name string
content string
want *uuid.UUID
}{
{
name: "empty content",
content: "",
want: nil,
},
{
name: "no URL",
content: "just a normal post",
want: nil,
},
{
name: "unrelated URL",
content: "check out https://example.com/foo",
want: nil,
},
{
name: "valid post URL",
content: "look at this https://example.com/posts/" + validID.String(),
want: &validID,
},
{
name: "different domain",
content: "see https://other-frontend.example.org/posts/" + validID.String(),
want: &validID,
},
{
name: "with subpath",
content: "https://example.com/app/posts/" + validID.String(),
want: &validID,
},
{
name: "post URL in middle of text",
content: "I saw https://example.com/posts/" + validID.String() + " and it was great",
want: &validID,
},
{
name: "multiple post URLs returns first",
content: "https://a.com/posts/" + validID.String() + " and https://b.com/posts/00000000-0000-0000-0000-000000000001",
want: &validID,
},
{
name: "invalid UUID format",
content: "https://example.com/posts/not-a-uuid",
want: nil,
},
{
name: "path without scheme",
content: "/posts/" + validID.String(),
want: nil,
},
{
name: "http scheme",
content: "http://localhost:3000/posts/" + validID.String(),
want: &validID,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ExtractPostReference(tt.content)
if tt.want == nil && got != nil {
t.Errorf("ExtractPostReference(%q) = %v, want nil", tt.content, got)
} else if tt.want != nil && (got == nil || *got != *tt.want) {
t.Errorf("ExtractPostReference(%q) = %v, want %v", tt.content, got, tt.want)
}
})
}
}

func TestExtractMentions_MaxMentionsPerPostCap(t *testing.T) {
t.Parallel()

Expand Down
Loading
Loading