Channel context menu: long-press/right-click a row → rename, favorite/unfavorite, add/change/remove icon (color or image), delete; removed star/delete row buttons; favorite + icon/color synced across devices
This commit is contained in:
1 parent
0d99ee5cf5
commit
b681e547e7
12 files changed
+574
-68
No files matched your search
@@ -2100,6 +2100,59 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
resp.id = frame.id
|
||||
await self._ws_server.broadcast(resp)
|
||||
|
||||
async def on_channel_favorite(self, frame: protocol.Frame, device_id: str) -> None:
|
||||
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
||||
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||
await self._ws_server.send_to(
|
||||
device_id,
|
||||
protocol.error(protocol.ERR_NOT_FOUND, "channel.favorite requires chat_id", id=frame.id),
|
||||
)
|
||||
return
|
||||
on = bool(frame.payload.get("on"))
|
||||
entry = self._channels.set_favorite(chat_id, on)
|
||||
if entry is None:
|
||||
await self._ws_server.send_to(
|
||||
device_id,
|
||||
protocol.error(protocol.ERR_NOT_FOUND, f"unknown chat_id {chat_id}", id=frame.id),
|
||||
)
|
||||
return
|
||||
# Reuse the renamed event shape: it carries the full entry (incl. the
|
||||
# new favorite flag) so every device reconciles the change.
|
||||
resp = protocol.channel_renamed(entry)
|
||||
resp.id = frame.id
|
||||
await self._ws_server.broadcast(resp)
|
||||
|
||||
async def on_channel_icon(self, frame: protocol.Frame, device_id: str) -> None:
|
||||
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
||||
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||
await self._ws_server.send_to(
|
||||
device_id,
|
||||
protocol.error(protocol.ERR_NOT_FOUND, "channel.icon requires chat_id", id=frame.id),
|
||||
)
|
||||
return
|
||||
payload = frame.payload
|
||||
icon = payload.get("icon")
|
||||
icon = icon if isinstance(icon, str) and icon else None
|
||||
color = payload.get("color")
|
||||
color = color if isinstance(color, str) and color else None
|
||||
# Guard against a runaway base64 blob (a channel icon is small).
|
||||
if icon is not None and len(icon) > 512 * 1024:
|
||||
await self._ws_server.send_to(
|
||||
device_id,
|
||||
protocol.error(protocol.ERR_UNSUPPORTED, "channel icon too large", id=frame.id),
|
||||
)
|
||||
return
|
||||
entry = self._channels.set_icon(chat_id, icon, color)
|
||||
if entry is None:
|
||||
await self._ws_server.send_to(
|
||||
device_id,
|
||||
protocol.error(protocol.ERR_NOT_FOUND, f"unknown chat_id {chat_id}", id=frame.id),
|
||||
)
|
||||
return
|
||||
resp = protocol.channel_renamed(entry)
|
||||
resp.id = frame.id
|
||||
await self._ws_server.broadcast(resp)
|
||||
|
||||
async def on_channel_delete(self, frame: protocol.Frame, device_id: str) -> None:
|
||||
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
||||
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||
|
||||
@@ -64,10 +64,25 @@ class ChannelDirectory:
|
||||
parent_chat_id TEXT,
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
created REAL NOT NULL DEFAULT 0
|
||||
created REAL NOT NULL DEFAULT 0,
|
||||
favorite INTEGER NOT NULL DEFAULT 0,
|
||||
icon TEXT,
|
||||
color TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
# Migrate existing DBs: add the cosmetic columns if missing.
|
||||
existing = {
|
||||
row[1] for row in self._conn.execute("PRAGMA table_info(channels)")
|
||||
}
|
||||
if "favorite" not in existing:
|
||||
self._conn.execute(
|
||||
"ALTER TABLE channels ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
if "icon" not in existing:
|
||||
self._conn.execute("ALTER TABLE channels ADD COLUMN icon TEXT")
|
||||
if "color" not in existing:
|
||||
self._conn.execute("ALTER TABLE channels ADD COLUMN color TEXT")
|
||||
self._conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS counters (
|
||||
@@ -206,6 +221,35 @@ class ChannelDirectory:
|
||||
self._conn.commit()
|
||||
return self.get(chat_id)
|
||||
|
||||
def set_favorite(self, chat_id: str, on: bool) -> Optional[Dict[str, Any]]:
|
||||
"""Toggle the cosmetic favorite flag (sorts to the top of the list)."""
|
||||
with self._lock:
|
||||
cur = self._conn.execute(
|
||||
"UPDATE channels SET favorite = ? WHERE chat_id = ? AND archived = 0",
|
||||
(1 if on else 0, chat_id),
|
||||
)
|
||||
self._conn.commit()
|
||||
if cur.rowcount == 0:
|
||||
return None
|
||||
return self.get(chat_id)
|
||||
|
||||
def set_icon(self, chat_id: str, icon: Optional[str], color: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||
"""Set the channel's cosmetic icon (base64 image) and/or avatar color.
|
||||
|
||||
``icon`` is a base64-encoded image (or ``None`` to clear it); ``color``
|
||||
is a ``#RRGGBB`` hex string (or ``None`` to clear it). Both are purely
|
||||
cosmetic and independent of the name / default flag.
|
||||
"""
|
||||
with self._lock:
|
||||
cur = self._conn.execute(
|
||||
"UPDATE channels SET icon = ?, color = ? WHERE chat_id = ? AND archived = 0",
|
||||
(icon, color, chat_id),
|
||||
)
|
||||
self._conn.commit()
|
||||
if cur.rowcount == 0:
|
||||
return None
|
||||
return self.get(chat_id)
|
||||
|
||||
def delete(self, chat_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Soft-delete (archive) a channel. History stays for search.
|
||||
|
||||
@@ -234,11 +278,11 @@ class ChannelDirectory:
|
||||
return _row_to_entry(row) if row else None
|
||||
|
||||
def list(self, include_archived: bool = False) -> List[Dict[str, Any]]:
|
||||
"""Directory listing. Default channel first, then creation order."""
|
||||
"""Directory listing. Default first, then favorites, then creation order."""
|
||||
sql = "SELECT * FROM channels"
|
||||
if not include_archived:
|
||||
sql += " WHERE archived = 0"
|
||||
sql += " ORDER BY is_default DESC, created ASC"
|
||||
sql += " ORDER BY is_default DESC, favorite DESC, created ASC"
|
||||
with self._lock:
|
||||
rows = self._conn.execute(sql).fetchall()
|
||||
return [_row_to_entry(r) for r in rows]
|
||||
@@ -318,6 +362,9 @@ def _row_to_entry(row: sqlite3.Row) -> Dict[str, Any]:
|
||||
"is_default": bool(row["is_default"]),
|
||||
"archived": bool(row["archived"]),
|
||||
"created": row["created"],
|
||||
"favorite": bool(row["favorite"]),
|
||||
"icon": row["icon"],
|
||||
"color": row["color"],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -58,6 +58,8 @@ TYPE_COMMENTARY = "commentary"
|
||||
TYPE_CHANNEL_CREATE = "channel.create"
|
||||
TYPE_CHANNEL_RENAME = "channel.rename"
|
||||
TYPE_CHANNEL_SET_DEFAULT = "channel.set_default"
|
||||
TYPE_CHANNEL_FAVORITE = "channel.favorite"
|
||||
TYPE_CHANNEL_ICON = "channel.icon"
|
||||
TYPE_CHANNEL_DELETE = "channel.delete"
|
||||
TYPE_CHANNEL_CREATED = "channel.created"
|
||||
TYPE_CHANNEL_RENAMED = "channel.renamed"
|
||||
@@ -434,6 +436,12 @@ def _channel_payload(entry: Dict[str, Any]) -> Dict[str, Any]:
|
||||
payload["is_default"] = True
|
||||
if entry.get("archived"):
|
||||
payload["archived"] = True
|
||||
if entry.get("favorite"):
|
||||
payload["favorite"] = True
|
||||
if entry.get("icon"):
|
||||
payload["icon"] = entry["icon"]
|
||||
if entry.get("color"):
|
||||
payload["color"] = entry["color"]
|
||||
return payload
|
||||
|
||||
|
||||
|
||||
@@ -370,6 +370,10 @@ class WsServer:
|
||||
await self._adapter.on_channel_rename(frame, device_id)
|
||||
elif frame.type == protocol.TYPE_CHANNEL_SET_DEFAULT:
|
||||
await self._adapter.on_channel_set_default(frame, device_id)
|
||||
elif frame.type == protocol.TYPE_CHANNEL_FAVORITE:
|
||||
await self._adapter.on_channel_favorite(frame, device_id)
|
||||
elif frame.type == protocol.TYPE_CHANNEL_ICON:
|
||||
await self._adapter.on_channel_icon(frame, device_id)
|
||||
elif frame.type == protocol.TYPE_CHANNEL_DELETE:
|
||||
await self._adapter.on_channel_delete(frame, device_id)
|
||||
elif frame.type == protocol.TYPE_CHANNEL_LIST:
|
||||
|
||||
Reference in new issue
Block a user