From 95accdf436ef9eff88b89bb6234e25ee849b14bc Mon Sep 17 00:00:00 2001 From: Kolkman Date: Wed, 26 Aug 2026 10:20:48 +0200 Subject: [PATCH] Post to mastodon from the profile page for logged in users --- VIBE/CHAT_LOG.md | 18 +++++++++++++++ VIBE/PROMPTS.md | 3 +++ backend/app/api/links.py | 32 +++++++++++++++++++++++++- backend/app/api/public.py | 1 + backend/app/database.py | 5 ++++ backend/app/services/link_service.py | 12 ++++++++++ backend/app/services/plugin_manager.py | 7 ++++++ backend/tests/test_api.py | 3 +++ backend/tests/test_database.py | 4 ++-- frontend/static/feed.js | 22 ++++++++++++++++++ frontend/static/mastodon.svg | 3 +++ frontend/static/style.css | 29 ++++++++++++++++++++++- frontend/templates/feed.html | 2 +- 13 files changed, 136 insertions(+), 5 deletions(-) create mode 100644 frontend/static/mastodon.svg diff --git a/VIBE/CHAT_LOG.md b/VIBE/CHAT_LOG.md index 8624c98..39eea53 100644 --- a/VIBE/CHAT_LOG.md +++ b/VIBE/CHAT_LOG.md @@ -652,3 +652,21 @@ Put the buttons on the right-hand side of the link item, make them smaller, and ### Assistant outcome Changed the entry action group to float at the right edge of user-specific link items, allowing content to wrap around it. Reduced Edit and Delete button padding, radius, and font size while preserving Catppuccin surface and danger colors. + +### User +Put the Mastodon button on the lower-right corner of the log entry. + +### Assistant outcome +Split the Mastodon control from the Edit/Delete action group and positioned it at the lower-right corner of user-specific link entries. Reserved bottom space in each entry to prevent overlap. + +### User +Change the Mastodon button location to immediately below the Edit and Delete buttons. + +### Assistant outcome +Changed the Mastodon button from absolute lower-right positioning to normal right-floated flow with `clear: right`, placing it directly beneath the Edit/Delete row while preserving the existing logo and posted-state styling. + +### User +When an authorized user is on their `//` page, show a button to post on Mastodon. After a message has been posted, keep the button functional but change its color and use the Mastodon logo inside it. + +### Assistant outcome +Added persisted Mastodon post state, an owner-scoped repost endpoint, and an owner-only Mastodon button on user-specific pages. The button includes a bundled Mastodon logo, says `Post to Mastodon` before posting, changes to the posted teal state and `Post again` afterward, and remains functional for reposting. Home-page entries remain free of controls. The complete Dockerized backend suite passed with 23 tests and the running container is healthy. diff --git a/VIBE/PROMPTS.md b/VIBE/PROMPTS.md index d8b376a..3bacaf4 100644 --- a/VIBE/PROMPTS.md +++ b/VIBE/PROMPTS.md @@ -119,6 +119,9 @@ 113. Only print the 'from' line if there is no title. Put the title directly behind the colon, and put all tags on the last line. 114. In the frontend do not show edit buttons on the home page even when a user is logged in. On the // page show the edit button on the right of the entry. Also add a delete button. 115. Put the buttons on the right hand side of the link item, make the buttons smaller and same color scheme +116. Put the mastodon button on the lower right corner of the log entry +117. Change that location to be immediately below the edit and delete button +116. When the user is authorized and on its // page show a Mastodon post button with logo; after posting keep it functional but change its color. ## Future entries diff --git a/backend/app/api/links.py b/backend/app/api/links.py index 2867fb3..2564050 100644 --- a/backend/app/api/links.py +++ b/backend/app/api/links.py @@ -5,7 +5,8 @@ from fastapi import APIRouter, Header, HTTPException, status import logging from pydantic import BaseModel -from backend.app.services.link_service import create_link, delete_link, list_public_links, list_tags, update_link +from backend.app.services.link_service import create_link, delete_link, get_link_tags, list_public_links, list_tags, mark_mastodon_posted, update_link +from backend.app.database import get_connection from backend.app.services.plugin_manager import plugin_manager from backend.app.services.token_service import validate_token @@ -47,6 +48,9 @@ def create_link_endpoint(payload: LinkCreate, authorization: str | None = Header except ValueError as error: raise HTTPException(status_code=422, detail=str(error)) from error plugin_results = plugin_manager.dispatch({'type': 'link_created', **record}) + mastodon_result = next((result for result in plugin_results if result.get('plugin') == 'mastodon'), None) + if mastodon_result and mastodon_result.get('status') == 'posted': + mark_mastodon_posted(record['id'], info['user_id'], mastodon_result.get('post_id')) if any(result.get('status') == 'failed' for result in plugin_results): logger.warning('One or more plugins failed for link_id=%s results=%s', record['id'], plugin_results) return record @@ -88,6 +92,32 @@ def delete_link_endpoint( return {'status': 'deleted', 'id': link_id} +@router.post('/links/{link_id}/mastodon') +def post_link_to_mastodon( + link_id: str, + authorization: str | None = Header(default=None), +): + if not authorization or not authorization.startswith('Bearer '): + raise HTTPException(status_code=401, detail='Missing or invalid Authorization header') + info = validate_token(authorization.replace('Bearer ', '', 1)) + if info is None: + raise HTTPException(status_code=401, detail='Token expired or invalid') + with get_connection() as conn: + row = conn.execute('SELECT * FROM links WHERE id = ? AND user_id = ?', (link_id, info['user_id'])).fetchone() + if row is None: + raise HTTPException(status_code=404, detail='Link not found or not owned by user') + event = dict(row) + with get_connection() as conn: + event['tags'] = get_link_tags(conn, link_id) + result = plugin_manager.post_to_mastodon({'type': 'link_created', **event}) + if result.get('status') == 'posted': + mark_mastodon_posted(link_id, info['user_id'], result.get('post_id')) + return {'status': 'posted', 'post_id': result.get('post_id')} + if result.get('status') == 'skipped': + raise HTTPException(status_code=409, detail='Mastodon is not enabled or configured') + raise HTTPException(status_code=502, detail=result.get('reason', 'Mastodon post failed')) + + @router.get('/links') def list_links(): return list_public_links() diff --git a/backend/app/api/public.py b/backend/app/api/public.py index ec075b3..f2a56cf 100644 --- a/backend/app/api/public.py +++ b/backend/app/api/public.py @@ -43,6 +43,7 @@ def public_feed( 'is_owner': item['user_id'] == current_user_id, 'can_edit': item['user_id'] == current_user_id, 'created_at': item['created_at'], + 'mastodon_posted': bool(item['mastodon_posted']), } for item in items ] diff --git a/backend/app/database.py b/backend/app/database.py index 4a215e0..25300f2 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -151,6 +151,11 @@ CREATE TABLE IF NOT EXISTS mastodon_oauth_states ( ); CREATE INDEX IF NOT EXISTS idx_mastodon_oauth_states_state_hash ON mastodon_oauth_states(state_hash); +'''), + (9, ''' +ALTER TABLE links ADD COLUMN mastodon_posted INTEGER NOT NULL DEFAULT 0; +ALTER TABLE links ADD COLUMN mastodon_post_id TEXT; +ALTER TABLE links ADD COLUMN mastodon_posted_at TEXT; '''), ] diff --git a/backend/app/services/link_service.py b/backend/app/services/link_service.py index ec677f0..9e5407d 100644 --- a/backend/app/services/link_service.py +++ b/backend/app/services/link_service.py @@ -167,6 +167,18 @@ def delete_link(link_id: str, user_id: str) -> bool: return cursor.rowcount > 0 +def mark_mastodon_posted(link_id: str, user_id: str, post_id: str | None) -> bool: + with get_connection() as conn: + cursor = conn.execute( + '''UPDATE links + SET mastodon_posted = 1, mastodon_post_id = ?, mastodon_posted_at = CURRENT_TIMESTAMP + WHERE id = ? AND user_id = ?''', + (post_id, link_id, user_id), + ) + conn.commit() + return cursor.rowcount > 0 + + def list_public_users(): with get_connection() as conn: rows = conn.execute( diff --git a/backend/app/services/plugin_manager.py b/backend/app/services/plugin_manager.py index a388e94..a8175ed 100644 --- a/backend/app/services/plugin_manager.py +++ b/backend/app/services/plugin_manager.py @@ -150,5 +150,12 @@ class PluginManager: logger.debug('Plugin dispatch result: plugin=%s event_id=%s result=%s', plugin.name, event.get('id'), result) return results + def post_to_mastodon(self, event): + self.refresh_from_db() + plugin = next(plugin for plugin in self.plugins if plugin.name == 'mastodon') + if not plugin.enabled: + return {'status': 'skipped', 'plugin': 'mastodon', 'reason': 'disabled'} + return plugin.handle_event(event) + plugin_manager = PluginManager() diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 1283a8f..c7d4353 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -403,6 +403,7 @@ def test_public_and_admin_pages_render_html(): feed_script = client.get('/static/feed.js?v=7').text assert 'if (item.is_owner && !showIdentity)' in feed_script assert 'deleteEntry(item, deleteButton)' in feed_script + assert 'postToMastodon(item, mastodonButton)' in feed_script assert 'tag.toLowerCase() === pref.tag.toLowerCase()' in feed_script assert 'return `${date.getFullYear()} ${months[date.getMonth()]} ${date.getDate()} - ${hours}:${minutes}`' in feed_script assert 'edit-tag-options' in feed_script @@ -452,6 +453,8 @@ def test_link_submission_posts_to_enabled_mastodon_plugin(): assert received['authorization'] == 'Bearer test-token' assert received['content_type'] == 'application/x-www-form-urlencoded' assert received['body'] == {'status': ['From my #LinkLog: A useful page\n\nWorth sharing\n\nfrom: https://example.com/useful\n\n#python #web']} + posted_item = next(item for item in client.get('/api/public/feed/alice', headers=headers).json() if item['id'] == response.json()['id']) + assert posted_item['mastodon_posted'] is True finally: server.shutdown() thread.join() diff --git a/backend/tests/test_database.py b/backend/tests/test_database.py index cd188f4..b1fdf95 100644 --- a/backend/tests/test_database.py +++ b/backend/tests/test_database.py @@ -10,7 +10,7 @@ def test_database_migrations_are_versioned_and_idempotent(): connection = sqlite3.connect(':memory:') apply_migrations(connection) - assert get_schema_version(connection) == 8 + assert get_schema_version(connection) == 9 tables = { row[0] for row in connection.execute( @@ -27,6 +27,6 @@ def test_database_migrations_are_versioned_and_idempotent(): assert set(DEFAULT_TAGS) <= seeded_tags apply_migrations(connection) - assert get_schema_version(connection) == 8 + assert get_schema_version(connection) == 9 connection.close() \ No newline at end of file diff --git a/frontend/static/feed.js b/frontend/static/feed.js index 41c0697..f65e290 100644 --- a/frontend/static/feed.js +++ b/frontend/static/feed.js @@ -130,8 +130,17 @@ function renderFeed(items, showIdentity = true) { deleteButton.className = 'delete-button'; deleteButton.textContent = 'Delete'; deleteButton.addEventListener('click', () => deleteEntry(item, deleteButton)); + const mastodonButton = document.createElement('button'); + mastodonButton.type = 'button'; + mastodonButton.className = `mastodon-button${item.mastodon_posted ? ' posted' : ''}`; + const mastodonLogo = document.createElement('img'); + mastodonLogo.src = '/static/mastodon.svg'; + mastodonLogo.alt = ''; + mastodonButton.append(mastodonLogo, document.createTextNode(item.mastodon_posted ? 'Post again' : 'Post to Mastodon')); + mastodonButton.addEventListener('click', () => postToMastodon(item, mastodonButton)); actions.append(editButton, deleteButton); article.appendChild(actions); + article.appendChild(mastodonButton); } if (showIdentity) { @@ -152,6 +161,19 @@ function renderFeed(items, showIdentity = true) { }); } +async function postToMastodon(item, button) { + button.disabled = true; + const response = await fetch(`/api/links/${encodeURIComponent(item.id)}/mastodon`, { + method: 'POST', + headers: {Authorization: `Bearer ${accessToken}`}, + }); + if (response.ok) { + await loadFeed(); + } else { + button.disabled = false; + } +} + async function deleteEntry(item, button) { if (!window.confirm(`Delete this link?`)) return; button.disabled = true; diff --git a/frontend/static/mastodon.svg b/frontend/static/mastodon.svg new file mode 100644 index 0000000..1e9c3ea --- /dev/null +++ b/frontend/static/mastodon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/static/style.css b/frontend/static/style.css index 7cd5eaf..ced18d8 100644 --- a/frontend/static/style.css +++ b/frontend/static/style.css @@ -445,6 +445,33 @@ button:disabled { font-size: 0.82rem; } +.mastodon-button { + clear: right; + float: right; + margin: 0 0 8px 16px; + display: inline-flex; + align-items: center; + gap: 5px; + min-width: 0; + padding: 5px 9px; + border-radius: 6px; + background: var(--surface-1); + border-color: var(--surface-2); + color: var(--text); + font-size: 0.82rem; +} + +.mastodon-button.posted { + background: var(--teal); + border-color: var(--teal); + color: var(--crust); +} + +.mastodon-button img { + width: 15px; + height: 15px; +} + .edit-form { display: grid; gap: 12px; @@ -493,7 +520,7 @@ button:disabled { } .link-item { - padding: 22px; + padding: 11px; background: rgba(49, 50, 68, 0.84); border: 1px solid var(--border); border-radius: 12px; diff --git a/frontend/templates/feed.html b/frontend/templates/feed.html index 0cce94b..47f09c5 100644 --- a/frontend/templates/feed.html +++ b/frontend/templates/feed.html @@ -79,6 +79,6 @@ - +