Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
185 changes: 142 additions & 43 deletions bill/components/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@
Orientation.SWITCH_DOMME: "Switch (Dom/me lean)",
Orientation.SWITCH_SUBMISSIVE: "Switch (submissive lean)",
}
DM_STATUS_OPTIONS = (
(DmStatus.OPEN, "Open", "DMs are welcome"),
(DmStatus.BY_REQUEST, "By Request", "Ask before sending a DM"),
(DmStatus.AFTER_TRIBUTE, "After Tribute", "DMs open after tribute"),
(DmStatus.CLOSED, "Closed", "Not accepting DMs"),
)
PROFILE_WIZARD_BUTTON_ACTIONS = (
"start",
"publish",
Expand All @@ -82,6 +88,7 @@
"identity-pronouns",
"identity-honourifics",
"identity-labels",
"identity-dm-status",
"link-select",
"creator-select",
)
Expand Down Expand Up @@ -275,12 +282,23 @@ def profile_wizard_view(
)
container.add_item(discord.ui.ActionRow(OrientationSelect(draft)))
elif current is DraftStepKey.IDENTITY:
linked = (
draft.target_scope is DraftScope.SERVER
and draft.server_mode is ServerProfileMode.LINKED
)
container.add_item(
discord.ui.TextDisplay(
"Choose the labels you want to show, then add your DM status, optional bio, "
"aliases, and public send-stat preference."
"Choose a DM status and the labels you want to show, then save any optional "
"bio, aliases, and public send-stat preference."
+ (
" Use global setting keeps this server's DM status linked to your global "
"profile."
if linked
else ""
)
)
)
container.add_item(discord.ui.ActionRow(DmStatusSelect(draft)))
container.add_item(
discord.ui.ActionRow(IdentitySelect(draft, "pronouns", PRONOUNS, "Choose pronouns"))
)
Expand Down Expand Up @@ -311,7 +329,7 @@ def profile_wizard_view(
discord.ui.ActionRow(
_button(
draft,
"Save DM status, bio & aliases",
"Save identity details",
"identity",
discord.ButtonStyle.primary,
)
Expand Down Expand Up @@ -368,9 +386,11 @@ def profile_wizard_view(
container.add_item(
discord.ui.TextDisplay(
"Review the completed sections above. You can edit any section now; "
"nothing becomes public until you choose **Publish**."
"nothing becomes public until you choose **Publish**. Change your DM status "
"below, including restoring the global setting for a linked profile."
)
)
container.add_item(discord.ui.ActionRow(DmStatusSelect(draft)))
edits = [
_button(draft, "Edit identity", "identity", discord.ButtonStyle.secondary),
_button(draft, "Edit links", "links", discord.ButtonStyle.secondary),
Expand Down Expand Up @@ -480,6 +500,45 @@ def __init__(
)


class DmStatusSelect(discord.ui.Select):
def __init__(self, draft: ProfileDraft) -> None:
linked = (
draft.target_scope is DraftScope.SERVER
and draft.server_mode is ServerProfileMode.LINKED
)
overridden = set(draft.document.overridden_fields)
inherited = linked and "dm_status" not in overridden
options = [
discord.SelectOption(
label=label,
value=status.value,
description=description,
default=(
draft.dm_status_selected
and not inherited
and draft.document.dm_status is status
),
)
for status, label, description in DM_STATUS_OPTIONS
]
if linked:
options.append(
discord.SelectOption(
label="Use global setting",
value="inherit",
description="Follow your current global DM status",
default=draft.dm_status_selected and inherited,
)
)
super().__init__(
custom_id=wizard_custom_id(draft, "identity-dm-status"),
placeholder="Choose a DM status",
min_values=1,
max_values=1,
options=options,
)


class LinkSelect(discord.ui.Select):
def __init__(self, draft: ProfileDraft, *, payment: bool) -> None:
options = [
Expand Down Expand Up @@ -768,7 +827,7 @@ async def callback(self, interaction: discord.Interaction[discord.Client]) -> No
expected_revision=draft.revision,
values=_partial_identity_values(draft, field, tuple(self.item.values)),
)
except WorkerAPIError as exc:
except (ValueError, WorkerAPIError) as exc:
await interaction.response.send_message(
f"Bill could not save that identity selection: {exc}",
ephemeral=True,
Expand Down Expand Up @@ -831,24 +890,34 @@ async def callback(self, interaction: discord.Interaction[discord.Client]) -> No


def _identity_values(
draft: ProfileDraft, pronouns: str, honourifics: str, labels: str, aliases: str, details: str
draft: ProfileDraft,
pronouns: str,
honourifics: str,
labels: str,
aliases: str,
stats_raw: str,
bio_raw: str,
) -> dict[str, object]:
orientation = draft.governing_orientation
if orientation is None:
raise ValueError("choose an orientation first")
honourific_available, label_available, aliases_available, _, stats_available = _caps(
orientation
)
detail_parts = [part.strip() for part in details.split("|", 2)]
if len(detail_parts) != 3:
raise ValueError("use: DM status | stats on/off | bio")
dm_raw, stats_raw, bio_raw = detail_parts
stats_raw = stats_raw.strip()
bio_raw = bio_raw.strip()
linked = (
draft.target_scope is DraftScope.SERVER and draft.server_mode is ServerProfileMode.LINKED
)
status = (
None if linked and dm_raw.casefold() == "inherit" else DmStatus(dm_raw.casefold()).value
)
existing_overrides = set(draft.document.overridden_fields)
if linked and "dm_status" not in existing_overrides:
status = None
elif draft.document.dm_status is not None:
status = draft.document.dm_status.value
elif linked:
raise ValueError("choose a DM status or Use global setting from the menu")
else:
raise ValueError("choose a DM status from the menu before saving identity")
if stats_raw.casefold() == "inherit" and linked:
stats: bool | None = None
elif stats_raw.casefold() in {"on", "yes", "true"}:
Expand Down Expand Up @@ -880,14 +949,13 @@ def _identity_values(
}
if linked:
overrides: list[str] = []
existing_overrides = set(draft.document.overridden_fields)
if pronouns.strip() or "pronouns" in existing_overrides:
overrides.append("pronouns")
if honourific_available and (honourifics.strip() or "honourifics" in existing_overrides):
overrides.append("honourifics")
if label_available and (labels.strip() or "submissive_labels" in existing_overrides):
overrides.append("submissive_labels")
if status is not None:
if "dm_status" in existing_overrides:
overrides.append("dm_status")
if bio_overridden:
overrides.append("bio")
Expand All @@ -904,35 +972,63 @@ def _partial_identity_values(
field: str,
selected: tuple[str, ...],
) -> dict[str, object]:
pronouns = selected if field == "pronouns" else draft.document.selections.pronouns
honourifics = selected if field == "honourifics" else draft.document.selections.honourifics
labels = selected if field == "labels" else draft.document.selections.submissive_labels
linked = (
draft.target_scope is DraftScope.SERVER and draft.server_mode is ServerProfileMode.LINKED
)
overrides = set(draft.document.overridden_fields)
if linked:
overrides.add(
{
"pronouns": "pronouns",
"honourifics": "honourifics",
"labels": "submissive_labels",
}[field]
)
honourific_available, label_available, aliases_available, _, stats_available = _caps(
draft.governing_orientation
)
if not honourific_available:
overrides.discard("honourifics")
if not label_available:
overrides.discard("submissive_labels")
if not aliases_available:
overrides.discard("aliases")
if not stats_available:
overrides.discard("public_send_stats")
status = draft.document.dm_status.value if draft.document.dm_status else None
if field == "dm-status":
if len(selected) != 1:
raise ValueError("choose one DM status")
if selected[0] == "inherit":
if not linked:
raise ValueError("only linked profiles can use the global DM setting")
status = None
overrides.discard("dm_status")
else:
try:
status = DmStatus(selected[0]).value
except ValueError as exc:
raise ValueError("choose a valid DM status") from exc
if linked:
overrides.add("dm_status")
elif field in {"pronouns", "honourifics", "labels"}:
if linked:
overrides.add(
{
"pronouns": "pronouns",
"honourifics": "honourifics",
"labels": "submissive_labels",
}[field]
)
else:
raise ValueError("choose a valid identity field")
pronouns = selected if field == "pronouns" else draft.document.selections.pronouns
honourifics = selected if field == "honourifics" else draft.document.selections.honourifics
labels = selected if field == "labels" else draft.document.selections.submissive_labels
values: dict[str, object] = {
"pronouns": list(pronouns),
"honourifics": list(honourifics),
"submissive_labels": list(labels),
"dm_status": (
draft.document.dm_status.value
if draft.document.dm_status
else (None if linked else DmStatus.OPEN.value)
),
"dm_status": status,
"bio": draft.document.bio,
"public_send_stats": draft.document.public_send_stats,
"aliases": list(draft.document.aliases),
"complete": False,
}
if field == "dm-status":
values["dm_status_selected"] = True
if linked:
values["overrides"] = sorted(overrides)
return values
Expand Down Expand Up @@ -1066,24 +1162,26 @@ def __init__(self, draft: ProfileDraft, message: discord.Message) -> None:
and draft.server_mode is ServerProfileMode.LINKED
)
overridden = set(draft.document.overridden_fields)
dm_default = (
draft.document.dm_status.value
if draft.document.dm_status
else ("inherit" if linked else DmStatus.OPEN.value)
)
stats_default = (
"inherit"
if linked and "public_send_stats" not in overridden
else ("on" if draft.document.public_send_stats else "off")
)
bio_default = draft.document.bio or "-" if not linked or "bio" in overridden else ""
self.details = discord.ui.TextInput(
label="DM status | stats on/off | bio (- clears)",
default=f"{dm_default} | {stats_default} | {bio_default}",
bio_default = (draft.document.bio or "-") if not linked or "bio" in overridden else ""
self.stats = discord.ui.TextInput(
label="Public send stats: on/off/inherit",
default=stats_default,
required=True,
max_length=380,
max_length=7,
)
self.bio = discord.ui.TextInput(
label="Bio (- clears; blank inherits when linked)",
default=bio_default,
required=False,
max_length=300,
style=discord.TextStyle.paragraph,
)
for field in (self.aliases, self.details):
for field in (self.aliases, self.stats, self.bio):
self.add_item(field)

async def on_submit(self, interaction: discord.Interaction[discord.Client]) -> None:
Expand All @@ -1100,7 +1198,8 @@ async def on_submit(self, interaction: discord.Interaction[discord.Client]) -> N
", ".join(self.draft.document.selections.honourifics),
", ".join(self.draft.document.selections.submissive_labels),
self.aliases.value,
self.details.value,
self.stats.value,
self.bio.value,
),
)
except (ValueError, WorkerAPIError) as exc:
Expand Down
2 changes: 2 additions & 0 deletions bill/worker_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ class ProfileDraft:
created_at: str | None
updated_at: str | None
published_at: str | None
dm_status_selected: bool = False


@dataclass(frozen=True, slots=True)
Expand Down Expand Up @@ -860,6 +861,7 @@ def _parse_draft(value: object) -> ProfileDraft:
_optional_string(data.get("created_at")),
_optional_string(data.get("updated_at")),
_optional_string(data.get("published_at")),
_bool(data.get("dm_status_selected"), "dm_status_selected"),
)

@staticmethod
Expand Down
15 changes: 11 additions & 4 deletions docs/profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,17 @@ member never start setup.

Drafts are private D1 records. Each mutation carries the last observed revision;
stale, foreign-user, wrong-guild, and completed controls fail safely.
Fixed identity selections are persisted without completing the identity step,
so a restart can reconstruct partial progress. Restart is explicit and
revision-checked. Publication changes the public root only during the final
review action.
Fixed identity selections and the DM-status menu are persisted without
completing the identity step, so a restart can reconstruct partial progress.
Global and independent profiles must deliberately choose Open, By Request,
After Tribute, or Closed before saving identity. A linked server profile may
instead choose **Use global setting**, which removes its server-specific DM
status override. That deliberate choice is tracked separately from the saved
status value, so active drafts created before the menu cannot mistake an old
implicit Open value for user intent. The review screen exposes the same
revision-bound menu for changing a status or restoring linked inheritance.
Restart is explicit and revision-checked. Publication changes the public root
only during the final review action.

The four orientations are Dom/me, Submissive, Switch leaning Dom/me, and Switch
leaning Submissive. All support pronouns, DM status, an optional 300-character
Expand Down
Loading
Loading