forked from retoor/devplacepy
feat: add telegram notification channel with outbox service and per-user preferences
Extend the notification system with a third channel (telegram) alongside existing in_app and push channels. Add `telegram_enabled` column to `notification_preferences` table, update `NOTIFICATION_CHANNELS` and `_NOTIFICATION_CHANNEL_COLUMNS` mappings, and set telegram default to off (`_NOTIFICATION_CHANNEL_DEFAULTS`). Create `telegram_outbox` table with columns for uid, user_uid, chat_id, text, status, attempts, created_at, and sent_at, plus an index on status/id for efficient polling. Register `TelegramOutboxService` in the service manager lifecycle. Update API documentation strings to describe the new channel and its pairing requirement. Extend admin notification defaults view to include telegram column. Pass `notif_telegram_paired` flag to profile template based on `telegram_store.is_paired()` check. Update `NotificationPrefForm` and `NotificationDefaultForm` model literals to accept "telegram" as a valid channel value.
This commit is contained in:
+47
-19
@@ -568,6 +568,20 @@ def init_db():
|
||||
telegram_links.create_column_by_example(column, example)
|
||||
_index(db, "telegram_links", "idx_telegram_links_chat", ["chat_id"])
|
||||
_index(db, "telegram_links", "idx_telegram_links_user", ["user_uid"])
|
||||
telegram_outbox = get_table("telegram_outbox")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
("user_uid", ""),
|
||||
("chat_id", 0),
|
||||
("text", ""),
|
||||
("status", "pending"),
|
||||
("attempts", 0),
|
||||
("created_at", ""),
|
||||
("sent_at", ""),
|
||||
):
|
||||
if not telegram_outbox.has_column(column):
|
||||
telegram_outbox.create_column_by_example(column, example)
|
||||
_index(db, "telegram_outbox", "idx_telegram_outbox_status", ["status", "id"])
|
||||
_index(db, "comments", "idx_comments_post_uid", ["post_uid"])
|
||||
_index(db, "comments", "idx_comments_target", ["target_type", "target_uid"])
|
||||
_index(db, "comments", "idx_comments_user_uid", ["user_uid"])
|
||||
@@ -964,6 +978,7 @@ def init_db():
|
||||
("notification_type", ""),
|
||||
("in_app_enabled", 1),
|
||||
("push_enabled", 1),
|
||||
("telegram_enabled", 1),
|
||||
("created_at", ""),
|
||||
("updated_at", ""),
|
||||
("deleted_at", ""),
|
||||
@@ -2628,15 +2643,21 @@ NOTIFICATION_TYPES = [
|
||||
{"key": "harvest_stolen", "label": "Farm raids", "description": "Someone steals a ready build from your Code Farm"},
|
||||
]
|
||||
|
||||
NOTIFICATION_CHANNELS = ("in_app", "push")
|
||||
_NOTIFICATION_CHANNEL_COLUMNS = {"in_app": "in_app_enabled", "push": "push_enabled"}
|
||||
NOTIFICATION_CHANNELS = ("in_app", "push", "telegram")
|
||||
_NOTIFICATION_CHANNEL_COLUMNS = {
|
||||
"in_app": "in_app_enabled",
|
||||
"push": "push_enabled",
|
||||
"telegram": "telegram_enabled",
|
||||
}
|
||||
_NOTIFICATION_CHANNEL_DEFAULTS = {"in_app": 1, "push": 1, "telegram": 0}
|
||||
_NOTIFICATION_TYPE_KEYS = {entry["key"] for entry in NOTIFICATION_TYPES}
|
||||
|
||||
_notification_prefs_cache = TTLCache(ttl=300, max_size=500)
|
||||
|
||||
|
||||
def _notification_default(notification_type: str, channel: str) -> bool:
|
||||
return get_int_setting(f"notif_default_{notification_type}_{channel}", 1) != 0
|
||||
fallback = _NOTIFICATION_CHANNEL_DEFAULTS.get(channel, 1)
|
||||
return get_int_setting(f"notif_default_{notification_type}_{channel}", fallback) != 0
|
||||
|
||||
|
||||
def get_notification_default(notification_type: str, channel: str) -> bool:
|
||||
@@ -2662,8 +2683,10 @@ def _notification_overrides(user_uid: str) -> dict:
|
||||
user_uid=user_uid, deleted_at=None
|
||||
):
|
||||
overrides[row["notification_type"]] = {
|
||||
"in_app": bool(row.get("in_app_enabled", 1)),
|
||||
"push": bool(row.get("push_enabled", 1)),
|
||||
channel: bool(
|
||||
row.get(column, _NOTIFICATION_CHANNEL_DEFAULTS.get(channel, 1))
|
||||
)
|
||||
for channel, column in _NOTIFICATION_CHANNEL_COLUMNS.items()
|
||||
}
|
||||
_notification_prefs_cache.set(user_uid, overrides)
|
||||
return overrides
|
||||
@@ -2684,17 +2707,18 @@ def get_notification_prefs(user_uid: str) -> list:
|
||||
for entry in NOTIFICATION_TYPES:
|
||||
key = entry["key"]
|
||||
override = overrides.get(key)
|
||||
in_app = (
|
||||
bool(override["in_app"]) if override else _notification_default(key, "in_app")
|
||||
)
|
||||
push = bool(override["push"]) if override else _notification_default(key, "push")
|
||||
channels = {
|
||||
channel: bool(override[channel])
|
||||
if override
|
||||
else _notification_default(key, channel)
|
||||
for channel in _NOTIFICATION_CHANNEL_COLUMNS
|
||||
}
|
||||
result.append(
|
||||
{
|
||||
"key": key,
|
||||
"label": entry["label"],
|
||||
"description": entry["description"],
|
||||
"in_app": in_app,
|
||||
"push": push,
|
||||
**channels,
|
||||
"customized": override is not None,
|
||||
}
|
||||
)
|
||||
@@ -2715,20 +2739,25 @@ def set_notification_pref(
|
||||
existing = table.find_one(user_uid=user_uid, notification_type=notification_type)
|
||||
if existing:
|
||||
values = {
|
||||
"in_app": bool(existing.get("in_app_enabled", 1)),
|
||||
"push": bool(existing.get("push_enabled", 1)),
|
||||
name: bool(
|
||||
existing.get(column, _NOTIFICATION_CHANNEL_DEFAULTS.get(name, 1))
|
||||
)
|
||||
for name, column in _NOTIFICATION_CHANNEL_COLUMNS.items()
|
||||
}
|
||||
else:
|
||||
values = {
|
||||
"in_app": _notification_default(notification_type, "in_app"),
|
||||
"push": _notification_default(notification_type, "push"),
|
||||
name: _notification_default(notification_type, name)
|
||||
for name in _NOTIFICATION_CHANNEL_COLUMNS
|
||||
}
|
||||
values[channel] = enabled
|
||||
columns = {
|
||||
column: 1 if values[name] else 0
|
||||
for name, column in _NOTIFICATION_CHANNEL_COLUMNS.items()
|
||||
}
|
||||
if existing:
|
||||
record = {
|
||||
"id": existing["id"],
|
||||
"in_app_enabled": 1 if values["in_app"] else 0,
|
||||
"push_enabled": 1 if values["push"] else 0,
|
||||
**columns,
|
||||
"updated_at": now,
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
@@ -2740,8 +2769,7 @@ def set_notification_pref(
|
||||
"uid": generate_uid(),
|
||||
"user_uid": user_uid,
|
||||
"notification_type": notification_type,
|
||||
"in_app_enabled": 1 if values["in_app"] else 0,
|
||||
"push_enabled": 1 if values["push"] else 0,
|
||||
**columns,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"deleted_at": None,
|
||||
|
||||
@@ -1932,7 +1932,7 @@ four ways to sign requests.
|
||||
"string",
|
||||
True,
|
||||
"push",
|
||||
"Either in_app or push.",
|
||||
"One of in_app, push or telegram (telegram is off by default and requires a paired Telegram account).",
|
||||
),
|
||||
field(
|
||||
"value",
|
||||
@@ -4869,7 +4869,7 @@ four ways to sign requests.
|
||||
"string",
|
||||
True,
|
||||
"push",
|
||||
"Either in_app or push.",
|
||||
"One of in_app, push or telegram (telegram is off by default and requires a paired Telegram account).",
|
||||
),
|
||||
field(
|
||||
"value",
|
||||
|
||||
@@ -108,6 +108,7 @@ from devplacepy.services.xmlrpc import XmlrpcService
|
||||
from devplacepy.services.audit import AuditService
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.telegram import TelegramService
|
||||
from devplacepy.services.telegram.outbox_service import TelegramOutboxService
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
@@ -253,6 +254,7 @@ async def lifespan(app: FastAPI):
|
||||
service_manager.register(XmlrpcService())
|
||||
service_manager.register(AuditService())
|
||||
service_manager.register(TelegramService())
|
||||
service_manager.register(TelegramOutboxService())
|
||||
if not os.environ.get("DEVPLACE_DISABLE_SERVICES"):
|
||||
await background.start()
|
||||
if acquire_service_lock():
|
||||
|
||||
@@ -238,13 +238,13 @@ class CustomizationToggleForm(BaseModel):
|
||||
|
||||
class NotificationPrefForm(BaseModel):
|
||||
notification_type: str = Field(min_length=1, max_length=40)
|
||||
channel: Literal["in_app", "push"]
|
||||
channel: Literal["in_app", "push", "telegram"]
|
||||
value: bool = False
|
||||
|
||||
|
||||
class NotificationDefaultForm(BaseModel):
|
||||
notification_type: str = Field(min_length=1, max_length=40)
|
||||
channel: Literal["in_app", "push"]
|
||||
channel: Literal["in_app", "push", "telegram"]
|
||||
value: bool = False
|
||||
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ def _notification_defaults_view() -> list:
|
||||
"description": entry["description"],
|
||||
"in_app": get_notification_default(entry["key"], "in_app"),
|
||||
"push": get_notification_default(entry["key"], "push"),
|
||||
"telegram": get_notification_default(entry["key"], "telegram"),
|
||||
}
|
||||
)
|
||||
return defaults
|
||||
|
||||
@@ -302,6 +302,11 @@ async def profile_page(
|
||||
if (tab == "notifications" and can_manage_customization)
|
||||
else []
|
||||
)
|
||||
notif_telegram_paired = (
|
||||
telegram_store.is_paired(profile_user["uid"])
|
||||
if (tab == "notifications" and can_manage_customization)
|
||||
else False
|
||||
)
|
||||
|
||||
base = site_url(request)
|
||||
robots = "noindex,follow" if posts_count < 2 else "index,follow"
|
||||
@@ -363,6 +368,7 @@ async def profile_page(
|
||||
"ai_modifier_sync": ai_modifier_sync,
|
||||
"ai_modifier_prompt": ai_modifier_prompt,
|
||||
"telegram_paired": telegram_paired,
|
||||
"notif_telegram_paired": notif_telegram_paired,
|
||||
"can_manage_customization": can_manage_customization,
|
||||
"cust_disable_global": customization_prefs["disable_global"],
|
||||
"cust_disable_pagetype": customization_prefs["disable_pagetype"],
|
||||
|
||||
@@ -764,6 +764,7 @@ class ProfileOut(_Out):
|
||||
ai_modifier_sync: bool = False
|
||||
ai_modifier_prompt: Optional[str] = None
|
||||
telegram_paired: bool = False
|
||||
notif_telegram_paired: bool = False
|
||||
can_manage_customization: bool = False
|
||||
cust_disable_global: bool = False
|
||||
cust_disable_pagetype: bool = False
|
||||
@@ -1258,6 +1259,8 @@ class GameLeaderboardEntryOut(_Out):
|
||||
xp: int = 0
|
||||
coins: int = 0
|
||||
total_harvests: int = 0
|
||||
prestige: int = 0
|
||||
score: int = 0
|
||||
|
||||
|
||||
class GameLeaderboardOut(_Out):
|
||||
|
||||
@@ -26,10 +26,10 @@ NOTIFICATION_ACTIONS: tuple[Action, ...] = (
|
||||
name="notification_list",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="List the user's notification preferences (in-app and push per type)",
|
||||
summary="List the user's notification preferences (in-app, push and telegram per type)",
|
||||
description=(
|
||||
"Returns every notification type with the user's current in-app and push setting and "
|
||||
"whether it has been customized. Use this before changing a setting."
|
||||
"Returns every notification type with the user's current in-app, push and telegram "
|
||||
"setting and whether it has been customized. Use this before changing a setting."
|
||||
),
|
||||
handler="notification",
|
||||
requires_auth=True,
|
||||
@@ -41,14 +41,15 @@ NOTIFICATION_ACTIONS: tuple[Action, ...] = (
|
||||
path="",
|
||||
summary="Enable or disable one notification type on one channel",
|
||||
description=(
|
||||
"Sets whether the user receives a given notification type on a given channel. The two "
|
||||
"channels are independent: in-app (shown on DevPlace) and push (sent to subscribed devices)."
|
||||
"Sets whether the user receives a given notification type on a given channel. The three "
|
||||
"channels are independent: in-app (shown on DevPlace), push (sent to subscribed devices) "
|
||||
"and telegram (delivered to the user's paired Telegram chat, off by default)."
|
||||
),
|
||||
handler="notification",
|
||||
requires_auth=True,
|
||||
params=(
|
||||
arg("notification_type", TYPES, required=True),
|
||||
arg("channel", "Either 'in_app' or 'push'.", required=True),
|
||||
arg("channel", "Either 'in_app', 'push' or 'telegram'.", required=True),
|
||||
arg(
|
||||
"value",
|
||||
"true to deliver this notification on this channel, false to suppress it.",
|
||||
|
||||
@@ -40,7 +40,7 @@ class NotificationController:
|
||||
def _channel(self, arguments: dict[str, Any]) -> str:
|
||||
channel = str(arguments.get("channel", "")).strip().lower()
|
||||
if channel not in NOTIFICATION_CHANNELS:
|
||||
raise ToolInputError("'channel' must be 'in_app' or 'push'.")
|
||||
raise ToolInputError("'channel' must be 'in_app', 'push' or 'telegram'.")
|
||||
return channel
|
||||
|
||||
def _value(self, arguments: dict[str, Any]) -> bool:
|
||||
|
||||
@@ -128,6 +128,34 @@ def level_progress(xp: int) -> dict:
|
||||
}
|
||||
|
||||
|
||||
SCORE_PRESTIGE = 5000
|
||||
SCORE_HARVEST = 10
|
||||
SCORE_COIN_DIVISOR = 20
|
||||
SCORE_CI = 250
|
||||
SCORE_PLOT = 200
|
||||
SCORE_PERK = 120
|
||||
SCORE_STREAK = 15
|
||||
SCORE_STREAK_CAP = 30
|
||||
|
||||
|
||||
def farm_score(farm: dict) -> int:
|
||||
def value(key: str, default: int = 0) -> int:
|
||||
raw = farm.get(key)
|
||||
return int(raw) if raw is not None else default
|
||||
|
||||
perk_total = sum(value(f"perk_{perk.key}") for perk in PERKS)
|
||||
return (
|
||||
value("xp")
|
||||
+ value("prestige") * SCORE_PRESTIGE
|
||||
+ value("total_harvests") * SCORE_HARVEST
|
||||
+ value("coins") // SCORE_COIN_DIVISOR
|
||||
+ (value("ci_tier", 1) - 1) * SCORE_CI
|
||||
+ (value("plot_count", STARTING_PLOTS) - STARTING_PLOTS) * SCORE_PLOT
|
||||
+ perk_total * SCORE_PERK
|
||||
+ min(value("streak"), SCORE_STREAK_CAP) * SCORE_STREAK
|
||||
)
|
||||
|
||||
|
||||
def unlocked_crops(level: int) -> list[Crop]:
|
||||
return [crop for crop in CROPS if crop.min_level <= level]
|
||||
|
||||
|
||||
@@ -475,7 +475,7 @@ def steal(thief: dict, owner: dict, slot: int) -> dict:
|
||||
def leaderboard(limit: int = 25) -> list[dict]:
|
||||
farms = sorted(
|
||||
_farms().find(),
|
||||
key=lambda row: (int(row.get("level", 1)), int(row.get("xp", 0)), int(row.get("total_harvests", 0))),
|
||||
key=economy.farm_score,
|
||||
reverse=True,
|
||||
)[:limit]
|
||||
if not farms:
|
||||
@@ -496,6 +496,8 @@ def leaderboard(limit: int = 25) -> list[dict]:
|
||||
"xp": int(farm.get("xp", 0)),
|
||||
"coins": int(farm.get("coins", 0)),
|
||||
"total_harvests": int(farm.get("total_harvests", 0)),
|
||||
"prestige": int(farm.get("prestige") or 0),
|
||||
"score": economy.farm_score(farm),
|
||||
}
|
||||
)
|
||||
return entries
|
||||
|
||||
@@ -173,6 +173,7 @@ class TelegramBridge:
|
||||
summary=f"Telegram paired for {user.get('username', user['uid'])}",
|
||||
metadata={"chat_id": chat_id},
|
||||
)
|
||||
await self._publish_pairing(user["uid"], True)
|
||||
return
|
||||
self._register_attempt(chat_id)
|
||||
await self._service.send(chat_id, BAD_CODE)
|
||||
@@ -198,6 +199,14 @@ class TelegramBridge:
|
||||
def _register_attempt(self, chat_id: int) -> None:
|
||||
self._attempts.setdefault(chat_id, []).append(time.monotonic())
|
||||
|
||||
async def _publish_pairing(self, user_uid: str, paired: bool) -> None:
|
||||
try:
|
||||
from devplacepy.services.pubsub import publish
|
||||
|
||||
await publish(f"user.{user_uid}.telegram", {"paired": paired})
|
||||
except Exception: # noqa: BLE001 - live pairing update is best-effort
|
||||
pass
|
||||
|
||||
async def _run_turn(
|
||||
self, chat_id: int, user: dict[str, Any], text: str, images: list[str]
|
||||
) -> None:
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from devplacepy.services.base import BaseService
|
||||
|
||||
from . import store
|
||||
|
||||
BATCH_LIMIT = 50
|
||||
|
||||
|
||||
class TelegramOutboxService(BaseService):
|
||||
title = "Telegram notifications"
|
||||
description = (
|
||||
"Delivers notifications to users who paired Telegram and opted a "
|
||||
"notification type into the Telegram channel. Drains the telegram_outbox "
|
||||
"queue on the service lock owner, where the Telegram bot worker runs, and "
|
||||
"sends each message as markdown. Rows wait when the bot is not running, so "
|
||||
"nothing is lost. Off by default for every notification type."
|
||||
)
|
||||
default_enabled = True
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(name="telegram_outbox", interval_seconds=2)
|
||||
self._delivered = 0
|
||||
self._failed = 0
|
||||
|
||||
async def run_once(self) -> None:
|
||||
from devplacepy.services.manager import service_manager
|
||||
|
||||
telegram = service_manager.get_service("telegram")
|
||||
if (
|
||||
telegram is None
|
||||
or not telegram.is_enabled()
|
||||
or not telegram.worker_alive()
|
||||
):
|
||||
return
|
||||
rows = store.pending_outbox(BATCH_LIMIT)
|
||||
for row in rows:
|
||||
delivered = await telegram.send_markdown(int(row["chat_id"]), row["text"])
|
||||
if delivered:
|
||||
store.mark_outbox_sent(row["id"])
|
||||
self._delivered += 1
|
||||
else:
|
||||
store.mark_outbox_failed(row["id"], int(row.get("attempts", 0)))
|
||||
self._failed += 1
|
||||
store.prune_outbox()
|
||||
if rows:
|
||||
self.log(f"drained {len(rows)} telegram notification(s)")
|
||||
|
||||
def collect_metrics(self) -> dict:
|
||||
return {
|
||||
"stats": [
|
||||
{"label": "Delivered", "value": self._delivered},
|
||||
{"label": "Failed", "value": self._failed},
|
||||
]
|
||||
}
|
||||
@@ -14,6 +14,9 @@ CODE_TTL_SETTING = "telegram_code_ttl_minutes"
|
||||
DEFAULT_CODE_TTL_MINUTES = 60
|
||||
PAIRINGS = "telegram_pairings"
|
||||
LINKS = "telegram_links"
|
||||
OUTBOX = "telegram_outbox"
|
||||
MAX_OUTBOX_ATTEMPTS = 3
|
||||
OUTBOX_RETENTION_HOURS = 24
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
@@ -149,3 +152,55 @@ def unpair(user_uid: str) -> int:
|
||||
|
||||
def is_paired(user_uid: str) -> bool:
|
||||
return link_for_user(user_uid) is not None
|
||||
|
||||
|
||||
def enqueue_outbox(user_uid: str, text: str) -> bool:
|
||||
link = link_for_user(user_uid)
|
||||
if not link:
|
||||
return False
|
||||
get_table(OUTBOX).insert(
|
||||
{
|
||||
"uid": generate_uid(),
|
||||
"user_uid": user_uid,
|
||||
"chat_id": int(link["chat_id"]),
|
||||
"text": text,
|
||||
"status": "pending",
|
||||
"attempts": 0,
|
||||
"created_at": _iso(_now()),
|
||||
"sent_at": None,
|
||||
}
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def pending_outbox(limit: int) -> list[dict[str, Any]]:
|
||||
table = get_table(OUTBOX)
|
||||
return list(table.find(status="pending", _limit=limit, order_by=["id"]))
|
||||
|
||||
|
||||
def mark_outbox_sent(row_id: int) -> None:
|
||||
get_table(OUTBOX).update(
|
||||
{"id": row_id, "status": "sent", "sent_at": _iso(_now())}, ["id"]
|
||||
)
|
||||
|
||||
|
||||
def mark_outbox_failed(row_id: int, attempts: int) -> None:
|
||||
table = get_table(OUTBOX)
|
||||
attempts += 1
|
||||
status = "failed" if attempts >= MAX_OUTBOX_ATTEMPTS else "pending"
|
||||
table.update({"id": row_id, "status": status, "attempts": attempts}, ["id"])
|
||||
|
||||
|
||||
def prune_outbox() -> int:
|
||||
table = get_table(OUTBOX)
|
||||
cutoff = _iso(_now() - timedelta(hours=OUTBOX_RETENTION_HOURS))
|
||||
removed = 0
|
||||
for row in table.find(status="sent"):
|
||||
if (row.get("created_at") or "") < cutoff:
|
||||
table.delete(id=row["id"])
|
||||
removed += 1
|
||||
for row in table.find(status="failed"):
|
||||
if (row.get("created_at") or "") < cutoff:
|
||||
table.delete(id=row["id"])
|
||||
removed += 1
|
||||
return removed
|
||||
|
||||
@@ -1216,7 +1216,12 @@ body:has(.page-messages) {
|
||||
|
||||
.card-link-host a:not(.card-link),
|
||||
.card-link-host button,
|
||||
.card-link-host form {
|
||||
.card-link-host form,
|
||||
.card-link-host label,
|
||||
.card-link-host input,
|
||||
.card-link-host select,
|
||||
.card-link-host textarea,
|
||||
.card-link-host summary {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
@@ -233,6 +233,7 @@
|
||||
}
|
||||
|
||||
.game-lb-rank {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
@@ -241,6 +242,10 @@
|
||||
|
||||
.game-lb-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
@@ -252,10 +257,20 @@
|
||||
}
|
||||
|
||||
.game-lb-level {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.game-lb-score {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
min-width: 4em;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.game-lb-empty {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
|
||||
@@ -506,3 +506,135 @@
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.landing-game {
|
||||
max-width: 960px;
|
||||
margin: 0 auto 3rem;
|
||||
padding: 0 2rem;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.landing-game-body {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--bg-card);
|
||||
padding: 2.25rem 2rem;
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.landing-game-glow {
|
||||
position: absolute;
|
||||
inset: -40% -10% auto -10%;
|
||||
height: 280px;
|
||||
background: radial-gradient(circle at 50% 0%, var(--accent) 0%, transparent 70%);
|
||||
opacity: 0.18;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.landing-game-body > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.landing-game-eyebrow {
|
||||
display: inline-block;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.landing-game-title {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1.9rem;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.landing-game-title span {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.landing-game-tagline {
|
||||
max-width: 640px;
|
||||
margin: 0 auto 1.25rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.02rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.landing-game-crops {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 1.85rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.landing-game-points {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 auto 1.5rem;
|
||||
max-width: 620px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.landing-game-points li {
|
||||
position: relative;
|
||||
padding-left: 1.5rem;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.landing-game-points li::before {
|
||||
content: "\2714";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.landing-game-cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.9rem 2.25rem;
|
||||
font-size: 1.05rem;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--accent);
|
||||
color: var(--white);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
transition: filter 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.landing-game-cta:hover {
|
||||
filter: brightness(1.08);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.landing-game-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.landing-game-cta {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,6 +185,7 @@ a.profile-stat-value:hover {
|
||||
border: 1px solid var(--border);
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scroll-margin-top: calc(var(--nav-height) + 1rem);
|
||||
}
|
||||
|
||||
.profile-tabs.tabs-managed {
|
||||
@@ -218,6 +219,13 @@ a.profile-stat-value:hover {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.profile-panel {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.profile-layout {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -688,7 +696,7 @@ a.profile-stat-value:hover {
|
||||
}
|
||||
|
||||
.notif-prefs {
|
||||
padding: 0.5rem 0;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.notif-prefs-intro {
|
||||
@@ -788,10 +796,51 @@ a.profile-stat-value:hover {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.notif-switch-disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.notif-prefs-hint {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.notif-prefs-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.notif-prefs-actions {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.notif-prefs-table th,
|
||||
.notif-prefs-table td {
|
||||
padding: 0.6rem 0.35rem;
|
||||
}
|
||||
|
||||
.notif-prefs-col {
|
||||
width: 3.25rem;
|
||||
}
|
||||
|
||||
.notif-prefs-desc {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.notif-switch-track {
|
||||
width: 2.1rem;
|
||||
height: 1.15rem;
|
||||
}
|
||||
|
||||
.notif-switch-track::after {
|
||||
width: 0.9rem;
|
||||
height: 0.9rem;
|
||||
}
|
||||
|
||||
.notif-switch input:checked + .notif-switch-track::after {
|
||||
transform: translateX(0.95rem);
|
||||
}
|
||||
}
|
||||
|
||||
.profile-achievements {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
|
||||
@@ -68,10 +68,10 @@ class Application {
|
||||
this.polls = new PollManager();
|
||||
this.apiKey = new ApiKeyManager();
|
||||
this.customizationToggle = new CustomizationToggle();
|
||||
this.notificationPrefs = new NotificationPrefs();
|
||||
this.notificationPrefs = new NotificationPrefs(this.pubsub);
|
||||
this.aiCorrection = new AiCorrection();
|
||||
this.aiModifier = new AiModifier();
|
||||
this.telegramPairing = new TelegramPairing();
|
||||
this.telegramPairing = new TelegramPairing(this.pubsub);
|
||||
this.adminNotificationDefaults = new AdminNotificationDefaults();
|
||||
this.userAiUsage = new UserAiUsage();
|
||||
this.tabs = new Tabs();
|
||||
|
||||
@@ -247,7 +247,7 @@ export class GameFarm {
|
||||
list.innerHTML = data.entries
|
||||
.map(
|
||||
(entry) =>
|
||||
`<li class="game-lb-row${entry.username === this.username ? " game-lb-self" : ""}"><span class="game-lb-rank">#${entry.rank}</span><a class="game-lb-name" href="/game/farm/${entry.username}">${entry.username}</a><span class="game-lb-level">Lv ${entry.level}</span></li>`
|
||||
`<li class="game-lb-row${entry.username === this.username ? " game-lb-self" : ""}"><span class="game-lb-rank">#${entry.rank}</span><a class="game-lb-name" href="/game/farm/${entry.username}">${entry.username}</a><span class="game-lb-level">Lv ${entry.level}</span><span class="game-lb-score">${entry.score.toLocaleString()}</span></li>`
|
||||
)
|
||||
.join("");
|
||||
} catch (error) {
|
||||
|
||||
@@ -3,15 +3,37 @@
|
||||
import { Http } from "./Http.js";
|
||||
|
||||
class NotificationPrefs {
|
||||
constructor() {
|
||||
constructor(pubsub) {
|
||||
this.root = document.querySelector("[data-notif-prefs]");
|
||||
if (!this.root) return;
|
||||
this.pubsub = pubsub;
|
||||
this.username = this.root.dataset.username;
|
||||
this.root
|
||||
.querySelectorAll("[data-notif-toggle]")
|
||||
.forEach((input) => this.bind(input));
|
||||
const reset = this.root.querySelector("[data-notif-reset]");
|
||||
if (reset) reset.addEventListener("click", () => this.reset(reset));
|
||||
this.subscribe();
|
||||
}
|
||||
|
||||
subscribe() {
|
||||
const uid = document.body.dataset.userUid || "";
|
||||
if (!uid || !this.pubsub) return;
|
||||
this.pubsub.subscribe(`user.${uid}.telegram`, (data) => {
|
||||
if (data && data.paired) this.enableTelegram();
|
||||
});
|
||||
}
|
||||
|
||||
enableTelegram() {
|
||||
this.root
|
||||
.querySelectorAll('[data-notif-toggle][data-channel="telegram"]')
|
||||
.forEach((input) => {
|
||||
input.disabled = false;
|
||||
const label = input.closest(".notif-switch");
|
||||
if (label) label.classList.remove("notif-switch-disabled");
|
||||
});
|
||||
const hint = this.root.querySelector(".notif-prefs-hint");
|
||||
if (hint) hint.hidden = true;
|
||||
}
|
||||
|
||||
bind(input) {
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
import { Http } from "./Http.js";
|
||||
|
||||
class TelegramPairing {
|
||||
constructor() {
|
||||
constructor(pubsub) {
|
||||
this.root = document.querySelector("[data-telegram-pairing]");
|
||||
if (!this.root) return;
|
||||
this.pubsub = pubsub;
|
||||
this.username = this.root.dataset.username;
|
||||
this.statusLabel = this.root.querySelector("[data-telegram-status]");
|
||||
this.requestBtn = this.root.querySelector("[data-telegram-request]");
|
||||
@@ -16,6 +17,21 @@ class TelegramPairing {
|
||||
this.message = this.root.querySelector("[data-telegram-status-msg]");
|
||||
if (this.requestBtn) this.requestBtn.addEventListener("click", () => this.request());
|
||||
if (this.unpairBtn) this.unpairBtn.addEventListener("click", () => this.unpair());
|
||||
this.subscribe();
|
||||
}
|
||||
|
||||
subscribe() {
|
||||
const uid = document.body.dataset.userUid || "";
|
||||
if (!uid || !this.pubsub) return;
|
||||
this.pubsub.subscribe(`user.${uid}.telegram`, (data) => {
|
||||
if (data && data.paired) this.onPaired();
|
||||
});
|
||||
}
|
||||
|
||||
onPaired() {
|
||||
this.setPaired(true);
|
||||
this.hideCode();
|
||||
this.setMessage("Telegram connected.", "success");
|
||||
}
|
||||
|
||||
async request() {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{% from "_macros.html" import content_url %}
|
||||
<article class="post-card">
|
||||
<article class="post-card card-link-host">
|
||||
{% set _href = content_url(item.post, 'posts') %}
|
||||
{% set _label = item.post.get('title') or 'View post' %}
|
||||
{% include "_card_link.html" %}
|
||||
{% set _created = item.post.get('created_at') %}{% include "_post_header.html" %}
|
||||
|
||||
<div class="post-topic">
|
||||
@@ -12,7 +15,7 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<div class="post-content rendered-content" data-card-href="{{ content_url(item.post, 'posts') }}">{{ render_content(item.post['content'][:300] ~ ('...' if item.post['content']|length > 300 else '')) }}</div>
|
||||
<div class="post-content rendered-content">{{ render_content(item.post['content'][:300] ~ ('...' if item.post['content']|length > 300 else '')) }}</div>
|
||||
|
||||
{% if item.attachments %}
|
||||
{% include "_attachment_display.html" %}
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
</div>
|
||||
<div class="notif-prefs" data-admin-notif>
|
||||
<div class="notif-prefs-intro">
|
||||
<p>These are the platform-wide defaults applied to every user who has not customized a notification on their own profile. Changing a default does not override a user's explicit choice.</p>
|
||||
<p>These are the platform-wide defaults applied to every user who has not customized a notification on their own profile. Changing a default does not override a user's explicit choice. Telegram delivery only reaches users who paired their Telegram account.</p>
|
||||
</div>
|
||||
<div class="notif-prefs-scroll">
|
||||
<table class="notif-prefs-table">
|
||||
<caption class="sr-only">Platform-wide default notification channels per notification type</caption>
|
||||
<thead>
|
||||
@@ -17,6 +18,7 @@
|
||||
<th scope="col">Notification</th>
|
||||
<th scope="col" class="notif-prefs-col">In-app</th>
|
||||
<th scope="col" class="notif-prefs-col">Push</th>
|
||||
<th scope="col" class="notif-prefs-col">Telegram</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -38,9 +40,16 @@
|
||||
<span class="notif-switch-track"></span>
|
||||
</label>
|
||||
</td>
|
||||
<td class="notif-prefs-col">
|
||||
<label class="notif-switch">
|
||||
<input type="checkbox" data-notif-toggle data-type="{{ pref.key }}" data-channel="telegram" {% if pref.telegram %}checked{% endif %} aria-label="Telegram default for {{ pref.label }}">
|
||||
<span class="notif-switch-track"></span>
|
||||
</label>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -86,7 +86,12 @@
|
||||
</div>
|
||||
|
||||
<aside class="feed-right" role="complementary" aria-label="Community">
|
||||
<div class="daily-topic-card">
|
||||
<div class="daily-topic-card card-link-host">
|
||||
{% if daily_topic.get('slug') %}
|
||||
{% set _href = "/news/" ~ daily_topic['slug'] %}
|
||||
{% set _label = daily_topic.get('title', '') %}
|
||||
{% include "_card_link.html" %}
|
||||
{% endif %}
|
||||
<div class="daily-topic-label">Daily Topic</div>
|
||||
{% if daily_topic.get('image_url', '') %}
|
||||
<img src="{{ daily_topic['image_url'] }}" alt="{{ daily_topic.get('title', '') }}" loading="lazy" class="daily-topic-image">
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
<a href="/feed">New post</a>
|
||||
<a href="/projects">Projects</a>
|
||||
<a href="/gists">Gists</a>
|
||||
<a href="/game">Code Farm</a>
|
||||
<a href="/notifications">Notifications</a>
|
||||
</div>
|
||||
</section>
|
||||
@@ -70,6 +71,40 @@
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="landing-game" aria-labelledby="landing-game-heading">
|
||||
<div class="landing-game-glow"></div>
|
||||
<div class="landing-game-body">
|
||||
<span class="landing-game-eyebrow">New on DevPlace</span>
|
||||
<h2 id="landing-game-heading" class="landing-game-title">
|
||||
Code Farm <span>- grow software, not vegetables</span>
|
||||
</h2>
|
||||
<p class="landing-game-tagline">
|
||||
Plant projects that build in real time, harvest coins and XP, upgrade your CI pipeline,
|
||||
and unlock the prestige grind. Then water your friends' builds - or raid the ones who left
|
||||
a harvest sitting too long.
|
||||
</p>
|
||||
<div class="landing-game-crops" aria-hidden="true">
|
||||
<span title="Shell Script">🐚</span>
|
||||
<span title="Python Script">🐍</span>
|
||||
<span title="Web App">📜</span>
|
||||
<span title="Go Service">🐹</span>
|
||||
<span title="Rust Engine">🦀</span>
|
||||
<span title="Compiler">λ</span>
|
||||
<span title="Kernel">⚙️</span>
|
||||
</div>
|
||||
<ul class="landing-game-points">
|
||||
<li>7 project tiers, from 30-second scripts to 2-hour kernels</li>
|
||||
<li>CI upgrades, a four-branch perk tree, and stacking prestige boosts</li>
|
||||
<li>Co-op watering, PvP raids, daily streaks, quests, and a global leaderboard</li>
|
||||
</ul>
|
||||
{% if user %}
|
||||
<a href="/game" class="landing-game-cta"><span class="icon">🌾</span> Play Code Farm →</a>
|
||||
{% else %}
|
||||
<a href="/auth/signup" class="landing-game-cta"><span class="icon">🌾</span> Join free to play →</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if landing_posts %}
|
||||
<section class="landing-section" aria-labelledby="landing-posts-heading">
|
||||
<div class="landing-section-header">
|
||||
@@ -78,7 +113,10 @@
|
||||
</div>
|
||||
<div class="landing-posts-grid">
|
||||
{% for item in landing_posts %}
|
||||
<article class="landing-post-card">
|
||||
<article class="landing-post-card card-link-host">
|
||||
{% set _href = "/posts/" ~ item.slug %}
|
||||
{% set _label = item.post.get('title') or 'View post' %}
|
||||
{% include "_card_link.html" %}
|
||||
<div class="landing-post-header">
|
||||
{% set _user = item.author %}
|
||||
{% set _size = 28 %}
|
||||
@@ -115,7 +153,10 @@
|
||||
</div>
|
||||
<div class="news-grid">
|
||||
{% for item in landing_articles %}
|
||||
<article class="news-card">
|
||||
<article class="news-card card-link-host">
|
||||
{% set _href = "/news/" ~ (item.slug or item.uid) %}
|
||||
{% set _label = item.title %}
|
||||
{% include "_card_link.html" %}
|
||||
{% if item.image_url %}
|
||||
<div class="news-card-image">
|
||||
<img src="{{ item.image_url }}" alt="{{ item.title }}" loading="lazy" class="image-fallback">
|
||||
|
||||
@@ -49,7 +49,10 @@
|
||||
|
||||
<ol class="leaderboard-list">
|
||||
{% for entry in entries %}
|
||||
<li class="leaderboard-row{% if is_self(user, entry['uid']) %} leaderboard-row-self{% endif %}">
|
||||
<li class="leaderboard-row card-link-host{% if is_self(user, entry['uid']) %} leaderboard-row-self{% endif %}">
|
||||
{% set _href = "/profile/" ~ entry['username'] %}
|
||||
{% set _label = entry['username'] %}
|
||||
{% include "_card_link.html" %}
|
||||
<span class="leaderboard-rank">#{{ entry['rank'] }}</span>
|
||||
{% set _user = entry %}{% set _size = 32 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
|
||||
{% set _user = entry %}{% set _class = "leaderboard-name" %}{% include "_user_link.html" %}
|
||||
|
||||
@@ -14,7 +14,10 @@
|
||||
{% if articles %}
|
||||
<div class="news-grid">
|
||||
{% for item in articles %}
|
||||
<article class="news-card">
|
||||
<article class="news-card card-link-host">
|
||||
{% set _href = "/news/" ~ (item.article['slug'] or item.article['uid']) %}
|
||||
{% set _label = item.article['title'] %}
|
||||
{% include "_card_link.html" %}
|
||||
{% if item.image_url %}
|
||||
<div class="news-card-image">
|
||||
<img src="{{ item.image_url }}" alt="{{ item.article['title'] }}" loading="lazy" class="image-fallback">
|
||||
|
||||
@@ -250,7 +250,7 @@
|
||||
<p class="customization-hint">Runs only when your text contains an inline `@ai instruction` directive: it executes that instruction and removes the `@ai` marker, using your API key. Applies to posts, comments, projects, gists, direct messages and your bio. Code and source files are never touched.</p>
|
||||
</div>
|
||||
|
||||
<div class="customization-card" data-telegram-pairing data-username="{{ profile_user['username'] }}">
|
||||
<div class="customization-card" id="telegram-pairing" data-telegram-pairing data-username="{{ profile_user['username'] }}">
|
||||
<div class="customization-head">
|
||||
<span class="customization-title">Telegram</span>
|
||||
</div>
|
||||
@@ -468,16 +468,16 @@
|
||||
{% endfor %}
|
||||
</details>
|
||||
|
||||
<div class="profile-tabs" data-profile-tabs>
|
||||
<a href="/profile/{{ profile_user['username'] }}?tab=posts" class="profile-tab {% if current_tab == 'posts' %}active{% endif %}" data-menu-icon="📝" data-menu-label="Posts"><span class="icon">📝</span> Posts</a>
|
||||
<a href="/profile/{{ profile_user['username'] }}?tab=projects" class="profile-tab {% if current_tab == 'projects' %}active{% endif %}" data-menu-icon="🚀" data-menu-label="Projects"><span class="icon">🚀</span> Projects</a>
|
||||
<a href="/profile/{{ profile_user['username'] }}?tab=gists" class="profile-tab {% if current_tab == 'gists' %}active{% endif %}" data-menu-icon="📝" data-menu-label="Gists"><span class="icon">📝</span> Gists</a>
|
||||
<a href="/profile/{{ profile_user['username'] }}?tab=media" class="profile-tab {% if current_tab == 'media' %}active{% endif %}" data-menu-icon="🖼️" data-menu-label="Media"><span class="icon">🖼️</span> Media</a>
|
||||
<a href="/profile/{{ profile_user['username'] }}?tab=activity" class="profile-tab {% if current_tab == 'activity' %}active{% endif %}" data-menu-icon="📊" data-menu-label="Activity"><span class="icon">📊</span> Activity</a>
|
||||
<a href="/profile/{{ profile_user['username'] }}?tab=followers" class="profile-tab {% if current_tab == 'followers' %}active{% endif %}" data-menu-icon="👥" data-menu-label="Followers ({{ followers_count }})"><span class="icon">👥</span> Followers ({{ followers_count }})</a>
|
||||
<a href="/profile/{{ profile_user['username'] }}?tab=following" class="profile-tab {% if current_tab == 'following' %}active{% endif %}" data-menu-icon="👤" data-menu-label="Following ({{ following_count }})"><span class="icon">👤</span> Following ({{ following_count }})</a>
|
||||
<div class="profile-tabs" id="profile-tabs" data-profile-tabs>
|
||||
<a href="/profile/{{ profile_user['username'] }}?tab=posts#profile-tabs" class="profile-tab {% if current_tab == 'posts' %}active{% endif %}" data-menu-icon="📝" data-menu-label="Posts"><span class="icon">📝</span> Posts</a>
|
||||
<a href="/profile/{{ profile_user['username'] }}?tab=projects#profile-tabs" class="profile-tab {% if current_tab == 'projects' %}active{% endif %}" data-menu-icon="🚀" data-menu-label="Projects"><span class="icon">🚀</span> Projects</a>
|
||||
<a href="/profile/{{ profile_user['username'] }}?tab=gists#profile-tabs" class="profile-tab {% if current_tab == 'gists' %}active{% endif %}" data-menu-icon="📝" data-menu-label="Gists"><span class="icon">📝</span> Gists</a>
|
||||
<a href="/profile/{{ profile_user['username'] }}?tab=media#profile-tabs" class="profile-tab {% if current_tab == 'media' %}active{% endif %}" data-menu-icon="🖼️" data-menu-label="Media"><span class="icon">🖼️</span> Media</a>
|
||||
<a href="/profile/{{ profile_user['username'] }}?tab=activity#profile-tabs" class="profile-tab {% if current_tab == 'activity' %}active{% endif %}" data-menu-icon="📊" data-menu-label="Activity"><span class="icon">📊</span> Activity</a>
|
||||
<a href="/profile/{{ profile_user['username'] }}?tab=followers#profile-tabs" class="profile-tab {% if current_tab == 'followers' %}active{% endif %}" data-menu-icon="👥" data-menu-label="Followers ({{ followers_count }})"><span class="icon">👥</span> Followers ({{ followers_count }})</a>
|
||||
<a href="/profile/{{ profile_user['username'] }}?tab=following#profile-tabs" class="profile-tab {% if current_tab == 'following' %}active{% endif %}" data-menu-icon="👤" data-menu-label="Following ({{ following_count }})"><span class="icon">👤</span> Following ({{ following_count }})</a>
|
||||
{% if is_owner or viewer_is_admin %}
|
||||
<a href="/profile/{{ profile_user['username'] }}?tab=notifications" class="profile-tab {% if current_tab == 'notifications' %}active{% endif %}" data-menu-icon="🔔" data-menu-label="Notifications"><span class="icon">🔔</span> Notifications</a>
|
||||
<a href="/profile/{{ profile_user['username'] }}?tab=notifications#profile-tabs" class="profile-tab {% if current_tab == 'notifications' %}active{% endif %}" data-menu-icon="🔔" data-menu-label="Notifications"><span class="icon">🔔</span> Notifications</a>
|
||||
{% endif %}
|
||||
<button type="button" class="profile-tab profile-tabs-more" aria-haspopup="menu" aria-label="More tabs" hidden><span class="icon">⋯</span> More</button>
|
||||
</div>
|
||||
@@ -540,6 +540,9 @@
|
||||
{% set pagination_query = "tab=media&" %}
|
||||
{% include "_pagination.html" %}
|
||||
{% elif current_tab == 'followers' or current_tab == 'following' %}
|
||||
{% if people %}
|
||||
<div class="profile-panel">
|
||||
{% endif %}
|
||||
{% for person in people %}
|
||||
<div class="follow-row">
|
||||
<a href="/profile/{{ person['username'] }}" class="follow-user">
|
||||
@@ -564,6 +567,9 @@
|
||||
{% else %}
|
||||
<div class="empty-state">{% if current_tab == 'followers' %}No followers yet.{% else %}Not following anyone yet.{% endif %}</div>
|
||||
{% endfor %}
|
||||
{% if people %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if follow_pagination and follow_pagination.total_pages > 1 %}
|
||||
<div class="follow-pagination">
|
||||
{% if follow_pagination.has_prev %}<a class="btn btn-ghost btn-sm" href="/profile/{{ profile_user['username'] }}?tab={{ current_tab }}&page={{ follow_pagination.prev_page }}">Previous</a>{% endif %}
|
||||
@@ -573,17 +579,20 @@
|
||||
{% endif %}
|
||||
{% elif current_tab == 'notifications' %}
|
||||
{% if is_owner or viewer_is_admin %}
|
||||
<div class="notif-prefs" data-notif-prefs data-username="{{ profile_user['username'] }}">
|
||||
<div class="notif-prefs profile-panel" data-notif-prefs data-username="{{ profile_user['username'] }}">
|
||||
<div class="notif-prefs-intro">
|
||||
{% if not is_owner %}<p class="notif-prefs-admin">Editing notification settings for <strong>{{ profile_user['username'] }}</strong>.</p>{% endif %}
|
||||
<p>Choose how you want to be notified for each event. <strong>In-app</strong> shows the notification on DevPlace; <strong>Push</strong> sends it to your subscribed devices.</p>
|
||||
<p>Choose how you want to be notified for each event. <strong>In-app</strong> shows the notification on DevPlace; <strong>Push</strong> sends it to your subscribed devices; <strong>Telegram</strong> delivers it to your paired Telegram chat.</p>
|
||||
{% if not notif_telegram_paired %}<p class="notif-prefs-hint">{% if is_owner %}Connect Telegram from the <a href="#telegram-pairing">Telegram panel</a> to enable Telegram notifications.{% else %}This user has not connected Telegram, so Telegram notifications cannot be delivered yet.{% endif %}</p>{% endif %}
|
||||
</div>
|
||||
<div class="notif-prefs-scroll">
|
||||
<table class="notif-prefs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Notification</th>
|
||||
<th class="notif-prefs-col">In-app</th>
|
||||
<th class="notif-prefs-col">Push</th>
|
||||
<th class="notif-prefs-col">Telegram</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -605,10 +614,17 @@
|
||||
<span class="notif-switch-track"></span>
|
||||
</label>
|
||||
</td>
|
||||
<td class="notif-prefs-col">
|
||||
<label class="notif-switch{% if not notif_telegram_paired %} notif-switch-disabled{% endif %}">
|
||||
<input type="checkbox" data-notif-toggle data-type="{{ pref.key }}" data-channel="telegram" aria-label="Telegram notifications for {{ pref.label }}" {% if pref.telegram %}checked{% endif %} {% if not notif_telegram_paired %}disabled{% endif %}>
|
||||
<span class="notif-switch-track"></span>
|
||||
</label>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="notif-prefs-actions">
|
||||
<button type="button" class="btn btn-ghost btn-sm" data-notif-reset>Reset to defaults</button>
|
||||
</div>
|
||||
|
||||
+18
-1
@@ -477,6 +477,15 @@ def _schedule_push(user_uid: str, message: str, target_url: str | None) -> None:
|
||||
task.add_done_callback(_push_tasks.discard)
|
||||
|
||||
|
||||
def _schedule_telegram(user_uid: str, message: str) -> None:
|
||||
from devplacepy.services.telegram import store as telegram_store
|
||||
|
||||
try:
|
||||
telegram_store.enqueue_outbox(user_uid, message)
|
||||
except Exception as e:
|
||||
logger.warning("Telegram delivery enqueue failed for %s: %s", user_uid, e)
|
||||
|
||||
|
||||
def create_notification(
|
||||
user_uid: str,
|
||||
notification_type: str,
|
||||
@@ -508,6 +517,7 @@ def _deliver_notification(
|
||||
|
||||
in_app = notification_enabled(user_uid, notification_type, "in_app")
|
||||
push = notification_enabled(user_uid, notification_type, "push")
|
||||
telegram = notification_enabled(user_uid, notification_type, "telegram")
|
||||
if in_app:
|
||||
get_table("notifications").insert(
|
||||
{
|
||||
@@ -524,6 +534,8 @@ def _deliver_notification(
|
||||
clear_unread_cache(user_uid)
|
||||
if push:
|
||||
_schedule_push(user_uid, message, target_url)
|
||||
if telegram:
|
||||
_schedule_telegram(user_uid, message)
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
audit.record_system(
|
||||
@@ -532,7 +544,12 @@ def _deliver_notification(
|
||||
target_type="notification",
|
||||
target_uid=related_uid,
|
||||
summary=f"notification created: {message}",
|
||||
metadata={"type": notification_type, "in_app": in_app, "push": push},
|
||||
metadata={
|
||||
"type": notification_type,
|
||||
"in_app": in_app,
|
||||
"push": push,
|
||||
"telegram": telegram,
|
||||
},
|
||||
links=[
|
||||
audit.recipient(user_uid),
|
||||
audit.link("source", "notification", related_uid),
|
||||
|
||||
Reference in New Issue
Block a user