mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-18 22:30:40 -07:00
Enhance story item management with trimming, splitting, and duplication features
- Updated StoryTrackEditor and StoryContent components to support trimming and splitting of story items. - Introduced new API endpoints for trimming, splitting, and duplicating story items, enhancing item management capabilities. - Refactored related hooks and state management to accommodate new functionalities. - Improved data models to include trim start and end times for better audio playback control. - Enhanced UI interactions for selecting and managing story items within the track editor.
This commit is contained in:
@@ -71,6 +71,8 @@ class StoryItem(Base):
|
||||
generation_id = Column(String, ForeignKey("generations.id"), nullable=False)
|
||||
start_time_ms = Column(Integer, nullable=False, default=0) # Milliseconds from story start
|
||||
track = Column(Integer, nullable=False, default=0) # Track number (0 = main track)
|
||||
trim_start_ms = Column(Integer, nullable=False, default=0) # Milliseconds trimmed from start
|
||||
trim_end_ms = Column(Integer, nullable=False, default=0) # Milliseconds trimmed from end
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
@@ -256,6 +258,24 @@ def _run_migrations(engine):
|
||||
conn.execute(text("ALTER TABLE story_items ADD COLUMN track INTEGER NOT NULL DEFAULT 0"))
|
||||
conn.commit()
|
||||
print("Added track column to story_items")
|
||||
|
||||
# Migration: Add trim columns if they don't exist
|
||||
# Re-check columns after potential track migration
|
||||
columns = {col['name'] for col in inspector.get_columns('story_items')}
|
||||
if 'trim_start_ms' not in columns:
|
||||
print("Migrating story_items: adding trim_start_ms column")
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("ALTER TABLE story_items ADD COLUMN trim_start_ms INTEGER NOT NULL DEFAULT 0"))
|
||||
conn.commit()
|
||||
print("Added trim_start_ms column to story_items")
|
||||
|
||||
columns = {col['name'] for col in inspector.get_columns('story_items')}
|
||||
if 'trim_end_ms' not in columns:
|
||||
print("Migrating story_items: adding trim_end_ms column")
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("ALTER TABLE story_items ADD COLUMN trim_end_ms INTEGER NOT NULL DEFAULT 0"))
|
||||
conn.commit()
|
||||
print("Added trim_end_ms column to story_items")
|
||||
|
||||
|
||||
def get_db():
|
||||
|
||||
+48
-7
@@ -770,14 +770,14 @@ async def add_story_item(
|
||||
return item
|
||||
|
||||
|
||||
@app.delete("/stories/{story_id}/items/{generation_id}")
|
||||
@app.delete("/stories/{story_id}/items/{item_id}")
|
||||
async def remove_story_item(
|
||||
story_id: str,
|
||||
generation_id: str,
|
||||
item_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Remove a generation from a story."""
|
||||
success = await stories.remove_item_from_story(story_id, generation_id, db)
|
||||
"""Remove a story item from a story."""
|
||||
success = await stories.remove_item_from_story(story_id, item_id, db)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Story item not found")
|
||||
return {"message": "Item removed successfully"}
|
||||
@@ -809,15 +809,56 @@ async def reorder_story_items(
|
||||
return items
|
||||
|
||||
|
||||
@app.put("/stories/{story_id}/items/{generation_id}/move", response_model=models.StoryItemDetail)
|
||||
@app.put("/stories/{story_id}/items/{item_id}/move", response_model=models.StoryItemDetail)
|
||||
async def move_story_item(
|
||||
story_id: str,
|
||||
generation_id: str,
|
||||
item_id: str,
|
||||
data: models.StoryItemMove,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Move a story item (update position and/or track)."""
|
||||
item = await stories.move_story_item(story_id, generation_id, data, db)
|
||||
item = await stories.move_story_item(story_id, item_id, data, db)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="Story item not found")
|
||||
return item
|
||||
|
||||
|
||||
@app.put("/stories/{story_id}/items/{item_id}/trim", response_model=models.StoryItemDetail)
|
||||
async def trim_story_item(
|
||||
story_id: str,
|
||||
item_id: str,
|
||||
data: models.StoryItemTrim,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Trim a story item (update trim_start_ms and trim_end_ms)."""
|
||||
item = await stories.trim_story_item(story_id, item_id, data, db)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="Story item not found or invalid trim values")
|
||||
return item
|
||||
|
||||
|
||||
@app.post("/stories/{story_id}/items/{item_id}/split", response_model=List[models.StoryItemDetail])
|
||||
async def split_story_item(
|
||||
story_id: str,
|
||||
item_id: str,
|
||||
data: models.StoryItemSplit,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Split a story item at a given time, creating two clips."""
|
||||
items = await stories.split_story_item(story_id, item_id, data, db)
|
||||
if items is None:
|
||||
raise HTTPException(status_code=404, detail="Story item not found or invalid split point")
|
||||
return items
|
||||
|
||||
|
||||
@app.post("/stories/{story_id}/items/{item_id}/duplicate", response_model=models.StoryItemDetail)
|
||||
async def duplicate_story_item(
|
||||
story_id: str,
|
||||
item_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Duplicate a story item, creating a copy with all properties."""
|
||||
item = await stories.duplicate_story_item(story_id, item_id, db)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="Story item not found")
|
||||
return item
|
||||
|
||||
@@ -221,6 +221,8 @@ class StoryItemDetail(BaseModel):
|
||||
generation_id: str
|
||||
start_time_ms: int
|
||||
track: int = 0
|
||||
trim_start_ms: int = 0
|
||||
trim_end_ms: int = 0
|
||||
created_at: datetime
|
||||
# Generation details
|
||||
profile_id: str
|
||||
@@ -277,3 +279,14 @@ class StoryItemMove(BaseModel):
|
||||
"""Request model for moving a story item (position and/or track)."""
|
||||
start_time_ms: int = Field(..., ge=0)
|
||||
track: int = 0
|
||||
|
||||
|
||||
class StoryItemTrim(BaseModel):
|
||||
"""Request model for trimming a story item."""
|
||||
trim_start_ms: int = Field(..., ge=0)
|
||||
trim_end_ms: int = Field(..., ge=0)
|
||||
|
||||
|
||||
class StoryItemSplit(BaseModel):
|
||||
"""Request model for splitting a story item."""
|
||||
split_time_ms: int = Field(..., ge=0) # Time within the clip to split at (relative to clip start)
|
||||
|
||||
+311
-11
@@ -18,6 +18,8 @@ from .models import (
|
||||
StoryItemCreate,
|
||||
StoryItemBatchUpdate,
|
||||
StoryItemMove,
|
||||
StoryItemTrim,
|
||||
StoryItemSplit,
|
||||
)
|
||||
from .database import Story as DBStory, StoryItem as DBStoryItem, Generation as DBGeneration, VoiceProfile as DBVoiceProfile
|
||||
from .utils.audio import load_audio, save_audio
|
||||
@@ -129,6 +131,8 @@ async def get_story(
|
||||
generation_id=item.generation_id,
|
||||
start_time_ms=item.start_time_ms,
|
||||
track=item.track,
|
||||
trim_start_ms=getattr(item, 'trim_start_ms', 0),
|
||||
trim_end_ms=getattr(item, 'trim_end_ms', 0),
|
||||
created_at=item.created_at,
|
||||
profile_id=generation.profile_id,
|
||||
profile_name=profile_name,
|
||||
@@ -252,6 +256,8 @@ async def add_item_to_story(
|
||||
generation_id=existing.generation_id,
|
||||
start_time_ms=existing.start_time_ms,
|
||||
track=existing.track,
|
||||
trim_start_ms=getattr(existing, 'trim_start_ms', 0),
|
||||
trim_end_ms=getattr(existing, 'trim_end_ms', 0),
|
||||
created_at=existing.created_at,
|
||||
profile_id=generation.profile_id,
|
||||
profile_name=profile.name if profile else "Unknown",
|
||||
@@ -321,6 +327,8 @@ async def add_item_to_story(
|
||||
generation_id=item.generation_id,
|
||||
start_time_ms=item.start_time_ms,
|
||||
track=item.track,
|
||||
trim_start_ms=getattr(item, 'trim_start_ms', 0),
|
||||
trim_end_ms=getattr(item, 'trim_end_ms', 0),
|
||||
created_at=item.created_at,
|
||||
profile_id=generation.profile_id,
|
||||
profile_name=profile.name if profile else "Unknown",
|
||||
@@ -336,7 +344,7 @@ async def add_item_to_story(
|
||||
|
||||
async def move_story_item(
|
||||
story_id: str,
|
||||
generation_id: str,
|
||||
item_id: str,
|
||||
data: StoryItemMove,
|
||||
db: Session,
|
||||
) -> Optional[StoryItemDetail]:
|
||||
@@ -345,7 +353,7 @@ async def move_story_item(
|
||||
|
||||
Args:
|
||||
story_id: Story ID
|
||||
generation_id: Generation ID of the item to move
|
||||
item_id: Story item ID
|
||||
data: New position and track data
|
||||
db: Database session
|
||||
|
||||
@@ -354,14 +362,14 @@ async def move_story_item(
|
||||
"""
|
||||
# Get the item
|
||||
item = db.query(DBStoryItem).filter_by(
|
||||
id=item_id,
|
||||
story_id=story_id,
|
||||
generation_id=generation_id
|
||||
).first()
|
||||
if not item:
|
||||
return None
|
||||
|
||||
# Get the generation
|
||||
generation = db.query(DBGeneration).filter_by(id=generation_id).first()
|
||||
generation = db.query(DBGeneration).filter_by(id=item.generation_id).first()
|
||||
if not generation:
|
||||
return None
|
||||
|
||||
@@ -386,6 +394,8 @@ async def move_story_item(
|
||||
generation_id=item.generation_id,
|
||||
start_time_ms=item.start_time_ms,
|
||||
track=item.track,
|
||||
trim_start_ms=getattr(item, 'trim_start_ms', 0),
|
||||
trim_end_ms=getattr(item, 'trim_end_ms', 0),
|
||||
created_at=item.created_at,
|
||||
profile_id=generation.profile_id,
|
||||
profile_name=profile.name if profile else "Unknown",
|
||||
@@ -401,23 +411,23 @@ async def move_story_item(
|
||||
|
||||
async def remove_item_from_story(
|
||||
story_id: str,
|
||||
generation_id: str,
|
||||
item_id: str,
|
||||
db: Session,
|
||||
) -> bool:
|
||||
"""
|
||||
Remove a generation from a story.
|
||||
Remove a story item from a story.
|
||||
|
||||
Args:
|
||||
story_id: Story ID
|
||||
generation_id: Generation ID to remove
|
||||
item_id: Story item ID to remove
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
True if removed, False if not found
|
||||
"""
|
||||
item = db.query(DBStoryItem).filter_by(
|
||||
id=item_id,
|
||||
story_id=story_id,
|
||||
generation_id=generation_id
|
||||
).first()
|
||||
if not item:
|
||||
return False
|
||||
@@ -434,6 +444,277 @@ async def remove_item_from_story(
|
||||
return True
|
||||
|
||||
|
||||
async def trim_story_item(
|
||||
story_id: str,
|
||||
item_id: str,
|
||||
data: StoryItemTrim,
|
||||
db: Session,
|
||||
) -> Optional[StoryItemDetail]:
|
||||
"""
|
||||
Trim a story item (update trim_start_ms and trim_end_ms).
|
||||
|
||||
Args:
|
||||
story_id: Story ID
|
||||
item_id: Story item ID
|
||||
data: Trim data (trim_start_ms, trim_end_ms)
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Updated item detail or None if not found
|
||||
"""
|
||||
# Get the item
|
||||
item = db.query(DBStoryItem).filter_by(
|
||||
id=item_id,
|
||||
story_id=story_id,
|
||||
).first()
|
||||
if not item:
|
||||
return None
|
||||
|
||||
# Get the generation
|
||||
generation = db.query(DBGeneration).filter_by(id=item.generation_id).first()
|
||||
if not generation:
|
||||
return None
|
||||
|
||||
# Validate trim values don't exceed duration
|
||||
max_duration_ms = int(generation.duration * 1000)
|
||||
if data.trim_start_ms + data.trim_end_ms >= max_duration_ms:
|
||||
return None # Invalid trim - would result in zero or negative duration
|
||||
|
||||
# Update trim values
|
||||
item.trim_start_ms = data.trim_start_ms
|
||||
item.trim_end_ms = data.trim_end_ms
|
||||
|
||||
# Update story updated_at
|
||||
story = db.query(DBStory).filter_by(id=story_id).first()
|
||||
if story:
|
||||
story.updated_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
|
||||
# Get profile name
|
||||
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
|
||||
|
||||
return StoryItemDetail(
|
||||
id=item.id,
|
||||
story_id=item.story_id,
|
||||
generation_id=item.generation_id,
|
||||
start_time_ms=item.start_time_ms,
|
||||
track=item.track,
|
||||
trim_start_ms=item.trim_start_ms,
|
||||
trim_end_ms=item.trim_end_ms,
|
||||
created_at=item.created_at,
|
||||
profile_id=generation.profile_id,
|
||||
profile_name=profile.name if profile else "Unknown",
|
||||
text=generation.text,
|
||||
language=generation.language,
|
||||
audio_path=generation.audio_path,
|
||||
duration=generation.duration,
|
||||
seed=generation.seed,
|
||||
instruct=generation.instruct,
|
||||
generation_created_at=generation.created_at,
|
||||
)
|
||||
|
||||
|
||||
async def split_story_item(
|
||||
story_id: str,
|
||||
item_id: str,
|
||||
data: StoryItemSplit,
|
||||
db: Session,
|
||||
) -> Optional[List[StoryItemDetail]]:
|
||||
"""
|
||||
Split a story item at a given time, creating two clips.
|
||||
|
||||
Args:
|
||||
story_id: Story ID
|
||||
item_id: Story item ID to split
|
||||
data: Split data (split_time_ms - time within clip to split at)
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
List of two updated item details (original and new) or None if not found/invalid
|
||||
"""
|
||||
# Get the item
|
||||
item = db.query(DBStoryItem).filter_by(
|
||||
id=item_id,
|
||||
story_id=story_id,
|
||||
).first()
|
||||
if not item:
|
||||
return None
|
||||
|
||||
# Get the generation
|
||||
generation = db.query(DBGeneration).filter_by(id=item.generation_id).first()
|
||||
if not generation:
|
||||
return None
|
||||
|
||||
# Calculate effective duration and validate split point
|
||||
current_trim_start = getattr(item, 'trim_start_ms', 0)
|
||||
current_trim_end = getattr(item, 'trim_end_ms', 0)
|
||||
original_duration_ms = int(generation.duration * 1000)
|
||||
effective_duration_ms = original_duration_ms - current_trim_start - current_trim_end
|
||||
|
||||
# Validate split_time_ms is within the effective duration
|
||||
if data.split_time_ms <= 0 or data.split_time_ms >= effective_duration_ms:
|
||||
return None # Invalid split point
|
||||
|
||||
# Calculate the absolute time in the original audio where we're splitting
|
||||
absolute_split_ms = current_trim_start + data.split_time_ms
|
||||
|
||||
# Update original clip: trim from the end
|
||||
item.trim_end_ms = original_duration_ms - absolute_split_ms
|
||||
|
||||
# Create new clip: starts after the split, trimmed from the start
|
||||
new_item = DBStoryItem(
|
||||
id=str(uuid.uuid4()),
|
||||
story_id=story_id,
|
||||
generation_id=item.generation_id, # Same generation, different trim
|
||||
start_time_ms=item.start_time_ms + data.split_time_ms,
|
||||
track=item.track,
|
||||
trim_start_ms=absolute_split_ms,
|
||||
trim_end_ms=current_trim_end,
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
|
||||
db.add(new_item)
|
||||
|
||||
# Update story updated_at
|
||||
story = db.query(DBStory).filter_by(id=story_id).first()
|
||||
if story:
|
||||
story.updated_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
db.refresh(new_item)
|
||||
|
||||
# Get profile name
|
||||
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
|
||||
profile_name = profile.name if profile else "Unknown"
|
||||
|
||||
# Build response items
|
||||
original_item_detail = StoryItemDetail(
|
||||
id=item.id,
|
||||
story_id=item.story_id,
|
||||
generation_id=item.generation_id,
|
||||
start_time_ms=item.start_time_ms,
|
||||
track=item.track,
|
||||
trim_start_ms=item.trim_start_ms,
|
||||
trim_end_ms=item.trim_end_ms,
|
||||
created_at=item.created_at,
|
||||
profile_id=generation.profile_id,
|
||||
profile_name=profile_name,
|
||||
text=generation.text,
|
||||
language=generation.language,
|
||||
audio_path=generation.audio_path,
|
||||
duration=generation.duration,
|
||||
seed=generation.seed,
|
||||
instruct=generation.instruct,
|
||||
generation_created_at=generation.created_at,
|
||||
)
|
||||
|
||||
new_item_detail = StoryItemDetail(
|
||||
id=new_item.id,
|
||||
story_id=new_item.story_id,
|
||||
generation_id=new_item.generation_id,
|
||||
start_time_ms=new_item.start_time_ms,
|
||||
track=new_item.track,
|
||||
trim_start_ms=new_item.trim_start_ms,
|
||||
trim_end_ms=new_item.trim_end_ms,
|
||||
created_at=new_item.created_at,
|
||||
profile_id=generation.profile_id,
|
||||
profile_name=profile_name,
|
||||
text=generation.text,
|
||||
language=generation.language,
|
||||
audio_path=generation.audio_path,
|
||||
duration=generation.duration,
|
||||
seed=generation.seed,
|
||||
instruct=generation.instruct,
|
||||
generation_created_at=generation.created_at,
|
||||
)
|
||||
|
||||
return [original_item_detail, new_item_detail]
|
||||
|
||||
|
||||
async def duplicate_story_item(
|
||||
story_id: str,
|
||||
item_id: str,
|
||||
db: Session,
|
||||
) -> Optional[StoryItemDetail]:
|
||||
"""
|
||||
Duplicate a story item, creating a copy with all properties.
|
||||
|
||||
Args:
|
||||
story_id: Story ID
|
||||
item_id: Story item ID to duplicate
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
New item detail or None if not found
|
||||
"""
|
||||
# Get the original item
|
||||
original_item = db.query(DBStoryItem).filter_by(
|
||||
id=item_id,
|
||||
story_id=story_id,
|
||||
).first()
|
||||
if not original_item:
|
||||
return None
|
||||
|
||||
# Get the generation
|
||||
generation = db.query(DBGeneration).filter_by(id=original_item.generation_id).first()
|
||||
if not generation:
|
||||
return None
|
||||
|
||||
# Calculate effective duration
|
||||
current_trim_start = getattr(original_item, 'trim_start_ms', 0)
|
||||
current_trim_end = getattr(original_item, 'trim_end_ms', 0)
|
||||
original_duration_ms = int(generation.duration * 1000)
|
||||
effective_duration_ms = original_duration_ms - current_trim_start - current_trim_end
|
||||
|
||||
# Create duplicate item - place it right after the original
|
||||
new_item = DBStoryItem(
|
||||
id=str(uuid.uuid4()),
|
||||
story_id=story_id,
|
||||
generation_id=original_item.generation_id, # Same generation as original
|
||||
start_time_ms=original_item.start_time_ms + effective_duration_ms + 200, # 200ms gap
|
||||
track=original_item.track,
|
||||
trim_start_ms=current_trim_start,
|
||||
trim_end_ms=current_trim_end,
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
|
||||
db.add(new_item)
|
||||
|
||||
# Update story updated_at
|
||||
story = db.query(DBStory).filter_by(id=story_id).first()
|
||||
if story:
|
||||
story.updated_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
db.refresh(new_item)
|
||||
|
||||
# Get profile name
|
||||
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
|
||||
|
||||
return StoryItemDetail(
|
||||
id=new_item.id,
|
||||
story_id=new_item.story_id,
|
||||
generation_id=new_item.generation_id,
|
||||
start_time_ms=new_item.start_time_ms,
|
||||
track=new_item.track,
|
||||
trim_start_ms=new_item.trim_start_ms,
|
||||
trim_end_ms=new_item.trim_end_ms,
|
||||
created_at=new_item.created_at,
|
||||
profile_id=generation.profile_id,
|
||||
profile_name=profile.name if profile else "Unknown",
|
||||
text=generation.text,
|
||||
language=generation.language,
|
||||
audio_path=generation.audio_path,
|
||||
duration=generation.duration,
|
||||
seed=generation.seed,
|
||||
instruct=generation.instruct,
|
||||
generation_created_at=generation.created_at,
|
||||
)
|
||||
|
||||
|
||||
async def update_story_item_times(
|
||||
story_id: str,
|
||||
data: StoryItemBatchUpdate,
|
||||
@@ -538,6 +819,8 @@ async def reorder_story_items(
|
||||
generation_id=item.generation_id,
|
||||
start_time_ms=item.start_time_ms,
|
||||
track=item.track,
|
||||
trim_start_ms=getattr(item, 'trim_start_ms', 0),
|
||||
trim_end_ms=getattr(item, 'trim_end_ms', 0),
|
||||
created_at=item.created_at,
|
||||
profile_id=generation.profile_id,
|
||||
profile_name=profile_name,
|
||||
@@ -602,14 +885,31 @@ async def export_story_audio(
|
||||
audio, sr = load_audio(str(audio_path), sample_rate=sample_rate)
|
||||
sample_rate = sr # Use actual sample rate from first file
|
||||
|
||||
# Get trim values
|
||||
trim_start_ms = getattr(item, 'trim_start_ms', 0)
|
||||
trim_end_ms = getattr(item, 'trim_end_ms', 0)
|
||||
|
||||
# Calculate effective duration
|
||||
original_duration_ms = int(generation.duration * 1000)
|
||||
effective_duration_ms = original_duration_ms - trim_start_ms - trim_end_ms
|
||||
|
||||
# Slice audio based on trim values
|
||||
trim_start_sample = int((trim_start_ms / 1000.0) * sample_rate)
|
||||
trim_end_sample = int((trim_end_ms / 1000.0) * sample_rate)
|
||||
|
||||
# Extract the trimmed portion
|
||||
if trim_end_ms > 0:
|
||||
trimmed_audio = audio[trim_start_sample:-trim_end_sample] if trim_end_sample > 0 else audio[trim_start_sample:]
|
||||
else:
|
||||
trimmed_audio = audio[trim_start_sample:]
|
||||
|
||||
# Store audio with its timecode info
|
||||
start_time_ms = item.start_time_ms
|
||||
duration_ms = int(generation.duration * 1000)
|
||||
|
||||
audio_data.append({
|
||||
'audio': audio,
|
||||
'audio': trimmed_audio,
|
||||
'start_time_ms': start_time_ms,
|
||||
'duration_ms': duration_ms,
|
||||
'duration_ms': effective_duration_ms,
|
||||
})
|
||||
except Exception:
|
||||
# Skip files that can't be loaded
|
||||
|
||||
Reference in New Issue
Block a user