Chat+ link previews: a link in a chat shows its page's title, text and picture, YouTube playable in place
X's web chat shows a link as a bare card. An X tab on a chat page may now ask native code (link_preview, x-capture only, chat pages only) for one link's preview. Native code fetches it: https and public hosts only (addresses checked and pinned), never X's hosts, no cookies or referrer, redirects re-checked, page head <= 512 KB, images <= 1.5 MB by magic bytes as data:, 30 fetches a minute, memory-only cache. Only the link leaves the page. On by default (owner, 2026-10-10).
This commit is contained in:
parent
7530fb1de7
commit
6d1798038c
21 changed files with 1455 additions and 519 deletions
|
|
@ -1,5 +1,10 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### New
|
||||||
|
- **Link previews in chats (Chat+).** X's web chat shows a link as a bare card with only the site's name. wayBetter shows the page's title, text and picture under the message, and for YouTube the video, playable right in the chat. Only the link is sent to its site (no cookies, never the message or who sent it), and nothing is saved. On by default; Chat+ → Link previews turns it off.
|
||||||
|
|
||||||
## 1.0.2
|
## 1.0.2
|
||||||
|
|
||||||
### New
|
### New
|
||||||
|
|
|
||||||
|
|
@ -751,6 +751,10 @@
|
||||||
"plugin.chat-plus.bookmarks.importBad": "This is not an export of starred messages.",
|
"plugin.chat-plus.bookmarks.importBad": "This is not an export of starred messages.",
|
||||||
"plugin.chat-plus.bookmarks.imported": "{count, plural, =0 {Nothing new to import} one {# starred message imported} other {# starred messages imported}}",
|
"plugin.chat-plus.bookmarks.imported": "{count, plural, =0 {Nothing new to import} one {# starred message imported} other {# starred messages imported}}",
|
||||||
"plugin.chat-plus.bookmarks.close": "Close",
|
"plugin.chat-plus.bookmarks.close": "Close",
|
||||||
|
"plugin.chat-plus.feature.linkPreviews.label": "Link previews",
|
||||||
|
"plugin.chat-plus.feature.linkPreviews.description": "Shows a preview under a link in a chat: the page's title, text and picture, and for YouTube the video, playable in place. To make one, the app asks the link's site for its preview itself: only the link is sent, without cookies, never the message or who sent it. Nothing is saved.",
|
||||||
|
"plugin.chat-plus.linkPreviews.play": "Play the video here",
|
||||||
|
"plugin.chat-plus.linkPreviews.open": "Open the link",
|
||||||
"plugin.chat-plus.feature.searchLoaded.label": "Find in conversation",
|
"plugin.chat-plus.feature.searchLoaded.label": "Find in conversation",
|
||||||
"plugin.chat-plus.feature.searchLoaded.description": "Ctrl+Shift+F in a chat searches the messages X has loaded so far and jumps to each match. It can load older messages while it is open.",
|
"plugin.chat-plus.feature.searchLoaded.description": "Ctrl+Shift+F in a chat searches the messages X has loaded so far and jumps to each match. It can load older messages while it is open.",
|
||||||
"plugin.chat-plus.searchLoaded.title": "Find in conversation",
|
"plugin.chat-plus.searchLoaded.title": "Find in conversation",
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "supported",
|
"tier": "supported",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "ar-x-fm",
|
"code": "ar-x-fm",
|
||||||
|
|
@ -28,7 +28,7 @@
|
||||||
"tier": "community",
|
"tier": "community",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"fallback": "ar",
|
"fallback": "ar",
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "bn",
|
"code": "bn",
|
||||||
|
|
@ -38,7 +38,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "community",
|
"tier": "community",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "eu",
|
"code": "eu",
|
||||||
|
|
@ -48,7 +48,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "community",
|
"tier": "community",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "en-GB",
|
"code": "en-GB",
|
||||||
|
|
@ -69,7 +69,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "community",
|
"tier": "community",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "ca",
|
"code": "ca",
|
||||||
|
|
@ -79,7 +79,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "community",
|
"tier": "community",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "zh-Hans",
|
"code": "zh-Hans",
|
||||||
|
|
@ -89,7 +89,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "community",
|
"tier": "community",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "zh-Hant",
|
"code": "zh-Hant",
|
||||||
|
|
@ -99,7 +99,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "supported",
|
"tier": "supported",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "hr",
|
"code": "hr",
|
||||||
|
|
@ -109,7 +109,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "community",
|
"tier": "community",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "cs",
|
"code": "cs",
|
||||||
|
|
@ -119,7 +119,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "community",
|
"tier": "community",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "da",
|
"code": "da",
|
||||||
|
|
@ -129,7 +129,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "community",
|
"tier": "community",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "nl",
|
"code": "nl",
|
||||||
|
|
@ -139,7 +139,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "supported",
|
"tier": "supported",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "fil",
|
"code": "fil",
|
||||||
|
|
@ -149,7 +149,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "community",
|
"tier": "community",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "fi",
|
"code": "fi",
|
||||||
|
|
@ -159,7 +159,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "community",
|
"tier": "community",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "fr",
|
"code": "fr",
|
||||||
|
|
@ -169,7 +169,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "supported",
|
"tier": "supported",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "gl",
|
"code": "gl",
|
||||||
|
|
@ -179,7 +179,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "community",
|
"tier": "community",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "de",
|
"code": "de",
|
||||||
|
|
@ -189,7 +189,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "supported",
|
"tier": "supported",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "el",
|
"code": "el",
|
||||||
|
|
@ -199,7 +199,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "community",
|
"tier": "community",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "gu",
|
"code": "gu",
|
||||||
|
|
@ -209,7 +209,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "community",
|
"tier": "community",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "ha",
|
"code": "ha",
|
||||||
|
|
@ -219,7 +219,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "community",
|
"tier": "community",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "he",
|
"code": "he",
|
||||||
|
|
@ -229,7 +229,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "community",
|
"tier": "community",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "hi",
|
"code": "hi",
|
||||||
|
|
@ -239,7 +239,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "supported",
|
"tier": "supported",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "hu",
|
"code": "hu",
|
||||||
|
|
@ -249,7 +249,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "community",
|
"tier": "community",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "ig",
|
"code": "ig",
|
||||||
|
|
@ -259,7 +259,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "community",
|
"tier": "community",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "id",
|
"code": "id",
|
||||||
|
|
@ -269,7 +269,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "supported",
|
"tier": "supported",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "ga",
|
"code": "ga",
|
||||||
|
|
@ -279,7 +279,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "community",
|
"tier": "community",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "it",
|
"code": "it",
|
||||||
|
|
@ -289,7 +289,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "supported",
|
"tier": "supported",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "ja",
|
"code": "ja",
|
||||||
|
|
@ -299,7 +299,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "supported",
|
"tier": "supported",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "kn",
|
"code": "kn",
|
||||||
|
|
@ -309,7 +309,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "community",
|
"tier": "community",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "ko",
|
"code": "ko",
|
||||||
|
|
@ -319,7 +319,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "supported",
|
"tier": "supported",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "ms",
|
"code": "ms",
|
||||||
|
|
@ -329,7 +329,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "community",
|
"tier": "community",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "mr",
|
"code": "mr",
|
||||||
|
|
@ -339,7 +339,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "community",
|
"tier": "community",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "nb",
|
"code": "nb",
|
||||||
|
|
@ -349,7 +349,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "community",
|
"tier": "community",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "fa",
|
"code": "fa",
|
||||||
|
|
@ -359,7 +359,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "community",
|
"tier": "community",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "pl",
|
"code": "pl",
|
||||||
|
|
@ -369,7 +369,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "community",
|
"tier": "community",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "pt",
|
"code": "pt",
|
||||||
|
|
@ -379,7 +379,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "supported",
|
"tier": "supported",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "ro",
|
"code": "ro",
|
||||||
|
|
@ -389,7 +389,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "community",
|
"tier": "community",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "ru",
|
"code": "ru",
|
||||||
|
|
@ -399,7 +399,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "supported",
|
"tier": "supported",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "sr",
|
"code": "sr",
|
||||||
|
|
@ -409,7 +409,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "community",
|
"tier": "community",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "sk",
|
"code": "sk",
|
||||||
|
|
@ -419,7 +419,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "community",
|
"tier": "community",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "es",
|
"code": "es",
|
||||||
|
|
@ -429,7 +429,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "supported",
|
"tier": "supported",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "sv",
|
"code": "sv",
|
||||||
|
|
@ -439,7 +439,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "community",
|
"tier": "community",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "ta",
|
"code": "ta",
|
||||||
|
|
@ -449,7 +449,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "community",
|
"tier": "community",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "th",
|
"code": "th",
|
||||||
|
|
@ -459,7 +459,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "supported",
|
"tier": "supported",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "tr",
|
"code": "tr",
|
||||||
|
|
@ -479,7 +479,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "community",
|
"tier": "community",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "ur",
|
"code": "ur",
|
||||||
|
|
@ -489,7 +489,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "community",
|
"tier": "community",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "vi",
|
"code": "vi",
|
||||||
|
|
@ -499,7 +499,7 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "supported",
|
"tier": "supported",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "yo",
|
"code": "yo",
|
||||||
|
|
@ -509,6 +509,6 @@
|
||||||
"status": "machine",
|
"status": "machine",
|
||||||
"tier": "community",
|
"tier": "community",
|
||||||
"reviewers": [],
|
"reviewers": [],
|
||||||
"coverage": 1
|
"coverage": 0.996
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -800,6 +800,10 @@
|
||||||
"plugin.chat-plus.personFilter.groupOnly": "Önce bir grup sohbeti aç",
|
"plugin.chat-plus.personFilter.groupOnly": "Önce bir grup sohbeti aç",
|
||||||
"plugin.chat-plus.personFilter.nobody": "Henüz seçilecek kimse yok: hiç mesaj yüklenmedi.",
|
"plugin.chat-plus.personFilter.nobody": "Henüz seçilecek kimse yok: hiç mesaj yüklenmedi.",
|
||||||
"plugin.chat-plus.personFilter.hint": "Şimdiye kadar yüklenen mesajları yazan kişiler. Daha fazlası için yukarı kaydır.",
|
"plugin.chat-plus.personFilter.hint": "Şimdiye kadar yüklenen mesajları yazan kişiler. Daha fazlası için yukarı kaydır.",
|
||||||
|
"plugin.chat-plus.feature.linkPreviews.label": "Link önizlemeleri",
|
||||||
|
"plugin.chat-plus.feature.linkPreviews.description": "Sohbetteki bir linkin altında önizlemesini gösterir: sayfanın başlığı, metni ve görseli; YouTube'da ise yerinde oynatılabilen video. Önizleme için uygulama linkin sitesine kendisi sorar: yalnızca link gider, çerezsiz; mesaj ya da gönderen asla gitmez. Hiçbir şey kaydedilmez.",
|
||||||
|
"plugin.chat-plus.linkPreviews.play": "Videoyu burada oynat",
|
||||||
|
"plugin.chat-plus.linkPreviews.open": "Linki aç",
|
||||||
"plugin.chat-plus.feature.keywords.label": "Sohbetlerde anahtar kelimeler",
|
"plugin.chat-plus.feature.keywords.label": "Sohbetlerde anahtar kelimeler",
|
||||||
"plugin.chat-plus.feature.keywords.description": "Kelimelerini içeren sohbet mesajlarını vurgular, sessize aldığın kelimeleri içeren mesajları katlar. Katlanmış bir mesaj tıklayınca açılır.",
|
"plugin.chat-plus.feature.keywords.description": "Kelimelerini içeren sohbet mesajlarını vurgular, sessize aldığın kelimeleri içeren mesajları katlar. Katlanmış bir mesaj tıklayınca açılır.",
|
||||||
"plugin.chat-plus.keywords.highlight": "Vurgulanacak kelimeler",
|
"plugin.chat-plus.keywords.highlight": "Vurgulanacak kelimeler",
|
||||||
|
|
|
||||||
2
apps/desktop/src-tauri/Cargo.lock
generated
2
apps/desktop/src-tauri/Cargo.lock
generated
|
|
@ -296,7 +296,9 @@ dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"chacha20poly1305",
|
"chacha20poly1305",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"reqwest",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
|
"rustls",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ desktop = [
|
||||||
"waybetter-host/app",
|
"waybetter-host/app",
|
||||||
"waybetter-host/net",
|
"waybetter-host/net",
|
||||||
"waybetter-host/macos-private-api",
|
"waybetter-host/macos-private-api",
|
||||||
|
"dep:reqwest",
|
||||||
|
"dep:rustls",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
|
|
@ -52,6 +54,9 @@ url = "2"
|
||||||
tauri-plugin-deep-link = { version = "2", optional = true }
|
tauri-plugin-deep-link = { version = "2", optional = true }
|
||||||
# Gallery folder picker, driven from Rust only (no JS capability grants the dialog plugin).
|
# Gallery folder picker, driven from Rust only (no JS capability grants the dialog plugin).
|
||||||
tauri-plugin-dialog = { version = "2", optional = true }
|
tauri-plugin-dialog = { version = "2", optional = true }
|
||||||
|
# Chat+ link previews (src/link_preview.rs): the same client the host's store uses, no cookies.
|
||||||
|
reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider"], optional = true }
|
||||||
|
rustls = { version = "0.23", default-features = false, features = ["ring"], optional = true }
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
# Encrypted settings sync (a folder the owner picks): Argon2id key from the passphrase,
|
# Encrypted settings sync (a folder the owner picks): Argon2id key from the passphrase,
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ fn main() {
|
||||||
"popover_close",
|
"popover_close",
|
||||||
"popover_open_vault",
|
"popover_open_vault",
|
||||||
"keyword_alert",
|
"keyword_alert",
|
||||||
|
"link_preview",
|
||||||
"tab_attach_image",
|
"tab_attach_image",
|
||||||
])),
|
])),
|
||||||
))
|
))
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
{
|
{
|
||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "x-capture",
|
"identifier": "x-capture",
|
||||||
"description": "X tabs: append-only draft capture, opening the wayBetter tab, asking for the drafts popover under its composer and closing it, asking whether a captured draft exists for exactly its own post page (yes/no, no text or ids) and reporting keyword matches about itself. No reads of the local vault, no shell, filesystem or generic APIs.",
|
"description": "X tabs: append-only draft capture, opening the wayBetter tab, asking for the drafts popover under its composer and closing it, asking whether a captured draft exists for exactly its own post page (yes/no, no text or ids), reporting keyword matches about itself and, on its own chat page only, asking for the preview of one link it shows (native fetch: public https hosts only, no cookies, size caps; src/link_preview.rs). No reads of the local vault, no shell, filesystem or generic APIs.",
|
||||||
"webviews": ["tab-*"],
|
"webviews": ["tab-*"],
|
||||||
"local": false,
|
"local": false,
|
||||||
"platforms": ["macOS", "windows", "linux"],
|
"platforms": ["macOS", "windows", "linux"],
|
||||||
"remote": { "urls": ["https://x.com/*"] },
|
"remote": { "urls": ["https://x.com/*"] },
|
||||||
"permissions": ["allow-capture-draft", "allow-open-panel", "allow-drafts-picker-open", "allow-drafts-popover-dismiss", "allow-drafts-hint", "allow-keyword-alert"]
|
"permissions": ["allow-capture-draft", "allow-open-panel", "allow-drafts-picker-open", "allow-drafts-popover-dismiss", "allow-drafts-hint", "allow-keyword-alert", "allow-link-preview"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ pub const FLAGS: &[&str] = &[
|
||||||
"chat.searchLoaded",
|
"chat.searchLoaded",
|
||||||
"chat.pins",
|
"chat.pins",
|
||||||
"chat.personFilter",
|
"chat.personFilter",
|
||||||
|
"chat.linkPreviews",
|
||||||
"chat.keywords",
|
"chat.keywords",
|
||||||
"mediaDownload",
|
"mediaDownload",
|
||||||
"rememberVideoVolume",
|
"rememberVideoVolume",
|
||||||
|
|
|
||||||
|
|
@ -233,6 +233,19 @@ async fn keyword_alert(webview: Webview, state: State<'_, AppState>, hit: Keywor
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Chat+ link previews (owner-approved network use, 2026-10-10): an X tab on its own chat page asks
|
||||||
|
/// for the preview of one link it shows. Only the link reaches native code; the fetch rules
|
||||||
|
/// (public https hosts, no cookies, pinned addresses, size caps, memory-only cache) are in link_preview.rs.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn link_preview(webview: Webview, url: String) -> Result<crate::link_preview::LinkPreview, String> {
|
||||||
|
policy::require_capture(webview.label(), &webview.url().map_err(|_| "FORBIDDEN: cannot verify origin")?)?;
|
||||||
|
let page = webview.url().map_err(|_| "FORBIDDEN: cannot verify origin")?;
|
||||||
|
if !crate::link_preview::is_chat_page(page.path()) {
|
||||||
|
return Err("FORBIDDEN: link previews are for X's chat pages only".into());
|
||||||
|
}
|
||||||
|
crate::link_preview::preview(&url).await
|
||||||
|
}
|
||||||
|
|
||||||
/// Tells an X tab whether its left navigation repeats one already on screen; the runtime hides it
|
/// Tells an X tab whether its left navigation repeats one already on screen; the runtime hides it
|
||||||
/// while the `singleSplitNav` feature is on. Queued in `__btPane` until the runtime exists.
|
/// while the `singleSplitNav` feature is on. Queued in `__btPane` until the runtime exists.
|
||||||
fn pane_script(nav_redundant: bool) -> String {
|
fn pane_script(nav_redundant: bool) -> String {
|
||||||
|
|
@ -578,6 +591,7 @@ pub fn run() {
|
||||||
desktop_popover::popover_close,
|
desktop_popover::popover_close,
|
||||||
desktop_popover::popover_open_vault,
|
desktop_popover::popover_open_vault,
|
||||||
keyword_alert,
|
keyword_alert,
|
||||||
|
link_preview,
|
||||||
crate::desktop_media::tab_attach_image,
|
crate::desktop_media::tab_attach_image,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ pub mod devmode;
|
||||||
pub mod distribution;
|
pub mod distribution;
|
||||||
pub mod gfonts;
|
pub mod gfonts;
|
||||||
pub mod hotfix;
|
pub mod hotfix;
|
||||||
|
pub mod link_preview;
|
||||||
pub mod media;
|
pub mod media;
|
||||||
pub mod model;
|
pub mod model;
|
||||||
pub mod palette_commands;
|
pub mod palette_commands;
|
||||||
|
|
|
||||||
607
apps/desktop/src-tauri/src/link_preview.rs
Normal file
607
apps/desktop/src-tauri/src/link_preview.rs
Normal file
|
|
@ -0,0 +1,607 @@
|
||||||
|
//! Chat+ link previews (owner-approved network use, 2026-10-10): X's web chat shows a link as a bare
|
||||||
|
//! card (an icon and the domain), so an X tab on a chat page may ask native code for the preview of
|
||||||
|
//! ONE link it shows. Native code fetches that link's page head (or YouTube's oEmbed) itself:
|
||||||
|
//!
|
||||||
|
//! - https only, no user info, default port, a domain name (no IP literal), not X's own hosts;
|
||||||
|
//! - every address the name resolves to must be public (no loopback, private, link-local, CGNAT,
|
||||||
|
//! multicast, documentation or unique-local ranges), and the connection is pinned to those
|
||||||
|
//! addresses, so a DNS answer cannot point the request into the owner's network;
|
||||||
|
//! - no cookies (no cookie store), no referrer, a plain user agent naming the app;
|
||||||
|
//! - redirects followed by hand, at most [`MAX_REDIRECTS`], each hop checked again;
|
||||||
|
//! - only an HTML head is read, at most [`MAX_PAGE_BYTES`]; an image at most [`MAX_IMAGE_BYTES`],
|
||||||
|
//! PNG/JPEG/GIF/WebP by its magic bytes only, handed back as a `data:` URL;
|
||||||
|
//! - the request carries the link and nothing else: no conversation, sender or message text.
|
||||||
|
//!
|
||||||
|
//! Results live in memory only (a bounded cache) and are never written anywhere.
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::net::IpAddr;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
pub const MAX_URL_LEN: usize = 2_048;
|
||||||
|
pub const MAX_REDIRECTS: usize = 4;
|
||||||
|
pub const MAX_PAGE_BYTES: usize = 512 * 1024;
|
||||||
|
pub const MAX_IMAGE_BYTES: usize = 1_536 * 1024;
|
||||||
|
pub const MAX_TITLE: usize = 200;
|
||||||
|
pub const MAX_DESCRIPTION: usize = 300;
|
||||||
|
pub const MAX_SITE: usize = 80;
|
||||||
|
pub const USER_AGENT: &str = "Mozilla/5.0 (compatible; wayBetter-LinkPreview/1.0; +https://waybetter.fyi/x/)";
|
||||||
|
|
||||||
|
/// What a chat tab gets back: plain text and, for a page, an image as a `data:` URL. A YouTube link
|
||||||
|
/// also carries its video id (the page offers YouTube's own player, a www.youtube.com frame X allows).
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct LinkPreview {
|
||||||
|
pub url: String,
|
||||||
|
pub site: String,
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub image: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub youtube: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// X's chat pages (`/i/chat`, `/messages`): the only pages that may ask for a preview.
|
||||||
|
pub fn is_chat_page(path: &str) -> bool {
|
||||||
|
["/i/chat", "/messages"].iter().any(|root| path == *root || path.starts_with(&format!("{root}/")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// X's own hosts: their links are X's to show, never fetched from here.
|
||||||
|
fn is_x_host(host: &str) -> bool {
|
||||||
|
["x.com", "twitter.com", "t.co", "twimg.com", "x.co"].iter().any(|own| host == *own || host.ends_with(&format!(".{own}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A link a chat tab may ask about: https, a domain name, default port, no user info, not X.
|
||||||
|
pub fn check_link(value: &str) -> Result<Url, String> {
|
||||||
|
if value.is_empty() || value.len() > MAX_URL_LEN || value.chars().any(|c| c.is_control() || c.is_whitespace()) {
|
||||||
|
return Err("VALIDATION: invalid link".into());
|
||||||
|
}
|
||||||
|
let url = Url::parse(value).map_err(|_| "VALIDATION: invalid link")?;
|
||||||
|
check_url(&url)?;
|
||||||
|
Ok(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_url(url: &Url) -> Result<(), String> {
|
||||||
|
if url.scheme() != "https" || !url.username().is_empty() || url.password().is_some() {
|
||||||
|
return Err("VALIDATION: only plain https links".into());
|
||||||
|
}
|
||||||
|
if url.port().is_some_and(|port| port != 443) {
|
||||||
|
return Err("VALIDATION: only the default port".into());
|
||||||
|
}
|
||||||
|
let host = match url.host() {
|
||||||
|
Some(url::Host::Domain(host)) => host.to_ascii_lowercase(),
|
||||||
|
_ => return Err("VALIDATION: the link must name a host".into()),
|
||||||
|
};
|
||||||
|
if !host.contains('.') || host.ends_with(".local") || host.ends_with(".localhost") || host.ends_with(".internal") || host == "localhost" {
|
||||||
|
return Err("VALIDATION: not a public host".into());
|
||||||
|
}
|
||||||
|
if is_x_host(&host) {
|
||||||
|
return Err("UNSUPPORTED: X's own links".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public unicast only: anything that could reach the owner's machine or network is refused.
|
||||||
|
pub fn is_public(ip: IpAddr) -> bool {
|
||||||
|
match ip {
|
||||||
|
IpAddr::V4(v4) => {
|
||||||
|
let [a, b, c, _] = v4.octets();
|
||||||
|
!(v4.is_loopback()
|
||||||
|
|| v4.is_private()
|
||||||
|
|| v4.is_link_local()
|
||||||
|
|| v4.is_broadcast()
|
||||||
|
|| v4.is_multicast()
|
||||||
|
|| v4.is_unspecified()
|
||||||
|
|| v4.is_documentation()
|
||||||
|
|| a == 0
|
||||||
|
|| (a == 100 && (64..=127).contains(&b))
|
||||||
|
|| (a == 192 && b == 0 && c == 0)
|
||||||
|
|| (a == 198 && (18..=19).contains(&b))
|
||||||
|
|| a >= 240)
|
||||||
|
}
|
||||||
|
IpAddr::V6(v6) => {
|
||||||
|
if let Some(v4) = v6.to_ipv4_mapped() {
|
||||||
|
return is_public(IpAddr::V4(v4));
|
||||||
|
}
|
||||||
|
let first = v6.segments()[0];
|
||||||
|
!(v6.is_loopback()
|
||||||
|
|| v6.is_unspecified()
|
||||||
|
|| v6.is_multicast()
|
||||||
|
|| (first & 0xfe00) == 0xfc00
|
||||||
|
|| (first & 0xffc0) == 0xfe80
|
||||||
|
|| first == 0x2001 && v6.segments()[1] == 0x0db8
|
||||||
|
|| first == 0x0064 && v6.segments()[1] == 0xff9b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The video id of a YouTube watch, short, shorts or embed link.
|
||||||
|
pub fn youtube_id(url: &Url) -> Option<String> {
|
||||||
|
let host = url.host_str()?.trim_start_matches("www.").trim_start_matches("m.").to_ascii_lowercase();
|
||||||
|
let id = match host.as_str() {
|
||||||
|
"youtu.be" => url.path_segments()?.next()?.to_string(),
|
||||||
|
"youtube.com" | "music.youtube.com" => {
|
||||||
|
let mut segments = url.path_segments()?;
|
||||||
|
match segments.next()? {
|
||||||
|
"watch" => url.query_pairs().find(|(k, _)| k == "v")?.1.into_owned(),
|
||||||
|
"shorts" | "embed" | "live" => segments.next()?.to_string(),
|
||||||
|
_ => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
(id.len() == 11 && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')).then_some(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plain text: entities decoded, control characters and runs of white space collapsed, capped.
|
||||||
|
pub fn clean(value: &str, max: usize) -> String {
|
||||||
|
let decoded = decode_entities(value);
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut space = false;
|
||||||
|
for c in decoded.chars() {
|
||||||
|
if c.is_whitespace() || c.is_control() {
|
||||||
|
space = !out.is_empty();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if matches!(c, '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if space {
|
||||||
|
out.push(' ');
|
||||||
|
space = false;
|
||||||
|
}
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
if out.chars().count() > max {
|
||||||
|
out = out.chars().take(max.saturating_sub(1)).collect::<String>().trim_end().to_string() + "\u{2026}";
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_entities(value: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(value.len());
|
||||||
|
let mut rest = value;
|
||||||
|
while let Some(at) = rest.find('&') {
|
||||||
|
out.push_str(&rest[..at]);
|
||||||
|
rest = &rest[at..];
|
||||||
|
let end = rest[..rest.len().min(12)].find(';');
|
||||||
|
let decoded = end.and_then(|end| {
|
||||||
|
let name = &rest[1..end];
|
||||||
|
let c = match name {
|
||||||
|
"amp" => Some('&'),
|
||||||
|
"lt" => Some('<'),
|
||||||
|
"gt" => Some('>'),
|
||||||
|
"quot" => Some('"'),
|
||||||
|
"apos" | "#39" => Some('\''),
|
||||||
|
"nbsp" => Some(' '),
|
||||||
|
_ if name.starts_with("#x") || name.starts_with("#X") => u32::from_str_radix(&name[2..], 16).ok().and_then(char::from_u32),
|
||||||
|
_ if name.starts_with('#') => name[1..].parse().ok().and_then(char::from_u32),
|
||||||
|
_ => None,
|
||||||
|
}?;
|
||||||
|
Some((c, end + 1))
|
||||||
|
});
|
||||||
|
match decoded {
|
||||||
|
Some((c, len)) => {
|
||||||
|
out.push(c);
|
||||||
|
rest = &rest[len..];
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
out.push('&');
|
||||||
|
rest = &rest[1..];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push_str(rest);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The attributes of one tag (`<meta a="b" c='d' e=f>`), names lowercased.
|
||||||
|
fn attributes(tag: &str) -> Vec<(String, String)> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let bytes = tag.as_bytes();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
while i < bytes.len() && (bytes[i].is_ascii_whitespace() || bytes[i] == b'/') {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
let start = i;
|
||||||
|
while i < bytes.len() && !bytes[i].is_ascii_whitespace() && bytes[i] != b'=' && bytes[i] != b'>' {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
if start == i {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = tag[start..i].to_ascii_lowercase();
|
||||||
|
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
let mut value = String::new();
|
||||||
|
if i < bytes.len() && bytes[i] == b'=' {
|
||||||
|
i += 1;
|
||||||
|
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
if i < bytes.len() && (bytes[i] == b'"' || bytes[i] == b'\'') {
|
||||||
|
let quote = bytes[i];
|
||||||
|
i += 1;
|
||||||
|
let from = i;
|
||||||
|
while i < bytes.len() && bytes[i] != quote {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
value = tag[from..i].to_string();
|
||||||
|
i += 1;
|
||||||
|
} else {
|
||||||
|
let from = i;
|
||||||
|
while i < bytes.len() && !bytes[i].is_ascii_whitespace() && bytes[i] != b'>' {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
value = tag[from..i].to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push((name, value));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a page's head says about itself: Open Graph first, then Twitter cards, then `<title>` and
|
||||||
|
/// the description meta. `image` is resolved against `base` and checked like a link.
|
||||||
|
#[derive(Debug, Default, PartialEq)]
|
||||||
|
pub struct PageMeta {
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
pub site: String,
|
||||||
|
pub image: Option<Url>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_head(html: &str, base: &Url) -> PageMeta {
|
||||||
|
let lower = html.to_ascii_lowercase();
|
||||||
|
let head_end = lower.find("</head").unwrap_or(lower.len());
|
||||||
|
let (html, lower) = (&html[..head_end], &lower[..head_end]);
|
||||||
|
let mut found: Vec<(String, String)> = Vec::new();
|
||||||
|
let mut at = 0;
|
||||||
|
while let Some(start) = lower[at..].find("<meta").map(|i| at + i) {
|
||||||
|
let Some(end) = lower[start..].find('>').map(|i| start + i) else { break };
|
||||||
|
let attrs = attributes(&html[start + 5..end]);
|
||||||
|
let key = attrs.iter().find(|(n, _)| n == "property" || n == "name").map(|(_, v)| v.to_ascii_lowercase());
|
||||||
|
let content = attrs.iter().find(|(n, _)| n == "content").map(|(_, v)| v.clone());
|
||||||
|
if let (Some(key), Some(content)) = (key, content) {
|
||||||
|
found.push((key, content));
|
||||||
|
}
|
||||||
|
at = end;
|
||||||
|
}
|
||||||
|
let get = |keys: &[&str]| keys.iter().find_map(|key| found.iter().find(|(k, v)| k == key && !v.trim().is_empty()).map(|(_, v)| v.clone()));
|
||||||
|
let title_tag = lower.find("<title").and_then(|start| {
|
||||||
|
let open = start + lower[start..].find('>')? + 1;
|
||||||
|
let close = open + lower[open..].find("</title")?;
|
||||||
|
Some(html[open..close].to_string())
|
||||||
|
});
|
||||||
|
let title = get(&["og:title", "twitter:title"]).or(title_tag).unwrap_or_default();
|
||||||
|
let description = get(&["og:description", "twitter:description", "description"]).unwrap_or_default();
|
||||||
|
let site = get(&["og:site_name", "application-name"]).unwrap_or_default();
|
||||||
|
let image = get(&["og:image:secure_url", "og:image", "og:image:url", "twitter:image", "twitter:image:src"])
|
||||||
|
.and_then(|value| base.join(decode_entities(value.trim()).as_str()).ok())
|
||||||
|
.filter(|url| check_url(url).is_ok());
|
||||||
|
PageMeta {
|
||||||
|
title: clean(&title, MAX_TITLE),
|
||||||
|
description: clean(&description, MAX_DESCRIPTION),
|
||||||
|
site: clean(&site, MAX_SITE),
|
||||||
|
image,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The image type by its first bytes; anything else (SVG included) is refused.
|
||||||
|
pub fn image_mime(bytes: &[u8]) -> Option<&'static str> {
|
||||||
|
match bytes {
|
||||||
|
[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, ..] => Some("image/png"),
|
||||||
|
[0xff, 0xd8, 0xff, ..] => Some("image/jpeg"),
|
||||||
|
[b'G', b'I', b'F', b'8', b'7' | b'9', b'a', ..] => Some("image/gif"),
|
||||||
|
[b'R', b'I', b'F', b'F', _, _, _, _, b'W', b'E', b'B', b'P', ..] => Some("image/webp"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The host as shown under a preview when the page names no site (`www.` dropped).
|
||||||
|
pub fn display_host(url: &Url) -> String {
|
||||||
|
clean(url.host_str().unwrap_or_default().trim_start_matches("www."), MAX_SITE)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "desktop")]
|
||||||
|
pub use fetch::preview;
|
||||||
|
|
||||||
|
#[cfg(feature = "desktop")]
|
||||||
|
mod fetch {
|
||||||
|
use super::*;
|
||||||
|
use base64::Engine;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::net::{SocketAddr, ToSocketAddrs};
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
const CACHE_ENTRIES: usize = 256;
|
||||||
|
const CACHE_TTL: Duration = Duration::from_secs(30 * 60);
|
||||||
|
/// At most this many fresh fetches per minute for the whole app (cached answers are free).
|
||||||
|
const PER_MINUTE: usize = 30;
|
||||||
|
|
||||||
|
type Cached = (Instant, Result<LinkPreview, String>);
|
||||||
|
|
||||||
|
fn cache() -> &'static Mutex<HashMap<String, Cached>> {
|
||||||
|
static CACHE: OnceLock<Mutex<HashMap<String, Cached>>> = OnceLock::new();
|
||||||
|
CACHE.get_or_init(Default::default)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn allow_fetch() -> bool {
|
||||||
|
static RECENT: OnceLock<Mutex<Vec<Instant>>> = OnceLock::new();
|
||||||
|
let mut recent = RECENT.get_or_init(Default::default).lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let now = Instant::now();
|
||||||
|
recent.retain(|at| now.duration_since(*at) < Duration::from_secs(60));
|
||||||
|
if recent.len() >= PER_MINUTE {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
recent.push(now);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The preview of one link: from the cache, else fetched under the rules above.
|
||||||
|
pub async fn preview(link: &str) -> Result<LinkPreview, String> {
|
||||||
|
let url = check_link(link)?;
|
||||||
|
let key = url.as_str().to_string();
|
||||||
|
if let Some((at, result)) = cache().lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
|
||||||
|
if at.elapsed() < CACHE_TTL {
|
||||||
|
return result.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !allow_fetch() {
|
||||||
|
return Err("BUSY: too many previews, try again shortly".into());
|
||||||
|
}
|
||||||
|
let result = match youtube_id(&url) {
|
||||||
|
Some(id) => youtube(&url, id).await,
|
||||||
|
None => page(&url).await,
|
||||||
|
};
|
||||||
|
let mut cache = cache().lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
if cache.len() >= CACHE_ENTRIES {
|
||||||
|
cache.retain(|_, (at, _)| at.elapsed() < CACHE_TTL);
|
||||||
|
if cache.len() >= CACHE_ENTRIES {
|
||||||
|
cache.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cache.insert(key, (Instant::now(), result.clone()));
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the host off the async runtime and keeps only public addresses (all must be public).
|
||||||
|
async fn public_addrs(url: &Url) -> Result<(String, Vec<SocketAddr>), String> {
|
||||||
|
let host = url.host_str().ok_or("VALIDATION: no host")?.to_string();
|
||||||
|
let lookup = host.clone();
|
||||||
|
let addrs: Vec<SocketAddr> = tauri::async_runtime::spawn_blocking(move || (lookup.as_str(), 443).to_socket_addrs().map(|a| a.collect::<Vec<_>>()))
|
||||||
|
.await
|
||||||
|
.map_err(|_| "NETWORK: lookup failed")?
|
||||||
|
.map_err(|_| "NETWORK: the host does not resolve")?;
|
||||||
|
if addrs.is_empty() || !addrs.iter().all(|addr| is_public(addr.ip())) {
|
||||||
|
return Err("FORBIDDEN: the host is not on the public internet".into());
|
||||||
|
}
|
||||||
|
Ok((host, addrs))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client(host: &str, addrs: &[SocketAddr]) -> Result<reqwest::Client, String> {
|
||||||
|
if rustls::crypto::CryptoProvider::get_default().is_none() {
|
||||||
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
|
}
|
||||||
|
// No cookie store: nothing is sent or kept. Redirects are followed by hand (each hop checked).
|
||||||
|
reqwest::Client::builder()
|
||||||
|
.user_agent(USER_AGENT)
|
||||||
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
|
.referer(false)
|
||||||
|
.https_only(true)
|
||||||
|
.connect_timeout(Duration::from_secs(6))
|
||||||
|
.timeout(Duration::from_secs(12))
|
||||||
|
.resolve_to_addrs(host, addrs)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("NETWORK: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET with hand-followed redirects; returns the final URL and up to `max` bytes of the body
|
||||||
|
/// (`stop` may end the read early, e.g. once the page head is complete).
|
||||||
|
async fn get(start: &Url, accept: &str, max: usize, stop: fn(&[u8]) -> bool) -> Result<(Url, String, Vec<u8>), String> {
|
||||||
|
let mut url = start.clone();
|
||||||
|
for _ in 0..=MAX_REDIRECTS {
|
||||||
|
check_url(&url)?;
|
||||||
|
let (host, addrs) = public_addrs(&url).await?;
|
||||||
|
let mut response = client(&host, &addrs)?
|
||||||
|
.get(url.clone())
|
||||||
|
.header(reqwest::header::ACCEPT, accept)
|
||||||
|
.header(reqwest::header::ACCEPT_LANGUAGE, "en;q=0.9, *;q=0.5")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("NETWORK: {}", e.without_url()))?;
|
||||||
|
let status = response.status();
|
||||||
|
if status.is_redirection() {
|
||||||
|
let location = response.headers().get(reqwest::header::LOCATION).and_then(|v| v.to_str().ok()).ok_or("NETWORK: redirect without a location")?;
|
||||||
|
url = url.join(location).map_err(|_| "NETWORK: invalid redirect")?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(format!("NETWORK: the site answered {}", status.as_u16()));
|
||||||
|
}
|
||||||
|
let mime = response.headers().get(reqwest::header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or("").to_ascii_lowercase();
|
||||||
|
if response.content_length().is_some_and(|len| len as usize > max) && !accept.starts_with("text/html") {
|
||||||
|
return Err("VALIDATION: too large".into());
|
||||||
|
}
|
||||||
|
let mut body = Vec::new();
|
||||||
|
while let Some(chunk) = response.chunk().await.map_err(|e| format!("NETWORK: {}", e.without_url()))? {
|
||||||
|
let room = max.saturating_sub(body.len());
|
||||||
|
body.extend_from_slice(&chunk[..chunk.len().min(room)]);
|
||||||
|
if body.len() >= max || stop(&body) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok((url, mime, body));
|
||||||
|
}
|
||||||
|
Err("NETWORK: too many redirects".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn head_complete(body: &[u8]) -> bool {
|
||||||
|
let tail = &body[body.len().saturating_sub(16 * 1024)..];
|
||||||
|
tail.windows(7).any(|w| w.eq_ignore_ascii_case(b"</head>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn never(_: &[u8]) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn page(url: &Url) -> Result<LinkPreview, String> {
|
||||||
|
let (final_url, mime, body) = get(url, "text/html,application/xhtml+xml;q=0.9", MAX_PAGE_BYTES, head_complete).await?;
|
||||||
|
if !(mime.starts_with("text/html") || mime.starts_with("application/xhtml")) {
|
||||||
|
return Err("UNSUPPORTED: not a web page".into());
|
||||||
|
}
|
||||||
|
let html = String::from_utf8_lossy(&body);
|
||||||
|
let meta = parse_head(&html, &final_url);
|
||||||
|
if meta.title.is_empty() && meta.image.is_none() {
|
||||||
|
return Err("UNSUPPORTED: the page has no preview".into());
|
||||||
|
}
|
||||||
|
let image = match &meta.image {
|
||||||
|
Some(image) => fetch_image(image).await.ok(),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
Ok(LinkPreview {
|
||||||
|
url: url.to_string(),
|
||||||
|
site: if meta.site.is_empty() { display_host(&final_url) } else { meta.site },
|
||||||
|
title: meta.title,
|
||||||
|
description: meta.description,
|
||||||
|
image,
|
||||||
|
youtube: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_image(url: &Url) -> Result<String, String> {
|
||||||
|
let (_, _, bytes) = get(url, "image/avif,image/webp,image/png,image/jpeg,image/gif;q=0.8", MAX_IMAGE_BYTES, never).await?;
|
||||||
|
if bytes.len() >= MAX_IMAGE_BYTES {
|
||||||
|
return Err("VALIDATION: image too large".into());
|
||||||
|
}
|
||||||
|
let mime = image_mime(&bytes).ok_or("UNSUPPORTED: not an image")?;
|
||||||
|
Ok(format!("data:{mime};base64,{}", base64::engine::general_purpose::STANDARD.encode(&bytes)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// YouTube: its oEmbed answer (title, channel) and the video's thumbnail; the player is YouTube's
|
||||||
|
/// own www.youtube.com, which X's page allows as a frame, loaded only when the user presses play.
|
||||||
|
async fn youtube(url: &Url, id: String) -> Result<LinkPreview, String> {
|
||||||
|
let mut oembed = Url::parse("https://www.youtube.com/oembed?format=json").expect("static URL");
|
||||||
|
oembed.query_pairs_mut().append_pair("url", &format!("https://www.youtube.com/watch?v={id}"));
|
||||||
|
let (_, _, body) = get(&oembed, "application/json", 64 * 1024, never).await?;
|
||||||
|
let value: serde_json::Value = serde_json::from_slice(&body).map_err(|_| "UNSUPPORTED: no video data")?;
|
||||||
|
let text = |key: &str, max: usize| clean(value.get(key).and_then(|v| v.as_str()).unwrap_or(""), max);
|
||||||
|
// The thumbnail comes as data too: X's chat page does not reliably load it from i.ytimg.com.
|
||||||
|
let thumbnail = Url::parse(&format!("https://i.ytimg.com/vi/{id}/hqdefault.jpg")).expect("checked id");
|
||||||
|
Ok(LinkPreview {
|
||||||
|
url: url.to_string(),
|
||||||
|
site: "YouTube".into(),
|
||||||
|
title: text("title", MAX_TITLE),
|
||||||
|
description: text("author_name", MAX_SITE),
|
||||||
|
image: fetch_image(&thumbnail).await.ok(),
|
||||||
|
youtube: Some(id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_plain_public_https_links() {
|
||||||
|
for ok in ["https://example.com/a?b=c", "https://www.youtube.com/watch?v=dQw4w9WgXcQ", "https://sub.example.co.uk:443/"] {
|
||||||
|
assert!(check_link(ok).is_ok(), "{ok}");
|
||||||
|
}
|
||||||
|
for bad in [
|
||||||
|
"http://example.com/",
|
||||||
|
"https://user:pw@example.com/",
|
||||||
|
"https://example.com:8443/",
|
||||||
|
"https://127.0.0.1/",
|
||||||
|
"https://[::1]/",
|
||||||
|
"https://192.168.0.1/",
|
||||||
|
"https://localhost/",
|
||||||
|
"https://printer.local/",
|
||||||
|
"https://intranet/",
|
||||||
|
"https://x.com/someone/status/1",
|
||||||
|
"https://pbs.twimg.com/media/a.jpg",
|
||||||
|
"https://t.co/abc",
|
||||||
|
"file:///etc/passwd",
|
||||||
|
"javascript:alert(1)",
|
||||||
|
"https://exa mple.com/",
|
||||||
|
"",
|
||||||
|
] {
|
||||||
|
assert!(check_link(bad).is_err(), "{bad}");
|
||||||
|
}
|
||||||
|
assert!(check_link(&format!("https://example.com/{}", "a".repeat(MAX_URL_LEN))).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn private_and_special_addresses_are_not_public() {
|
||||||
|
for ip in ["127.0.0.1", "10.1.2.3", "172.16.0.1", "192.168.0.192", "169.254.1.1", "100.64.0.1", "0.0.0.0", "224.0.0.1", "255.255.255.255", "198.18.0.1", "::1", "fe80::1", "fd00::1", "::ffff:192.168.0.1", "2001:db8::1", "64:ff9b::a00:1"] {
|
||||||
|
assert!(!is_public(ip.parse().unwrap()), "{ip}");
|
||||||
|
}
|
||||||
|
for ip in ["1.1.1.1", "142.250.185.78", "2606:4700:4700::1111"] {
|
||||||
|
assert!(is_public(ip.parse().unwrap()), "{ip}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chat_pages_only() {
|
||||||
|
for ok in ["/i/chat", "/i/chat/1-2", "/messages", "/messages/1-2"] {
|
||||||
|
assert!(is_chat_page(ok), "{ok}");
|
||||||
|
}
|
||||||
|
for bad in ["/home", "/i/chatter", "/messagesx", "/someone/status/1", "/"] {
|
||||||
|
assert!(!is_chat_page(bad), "{bad}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn youtube_ids() {
|
||||||
|
let id = |s: &str| youtube_id(&Url::parse(s).unwrap());
|
||||||
|
assert_eq!(id("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1"), Some("dQw4w9WgXcQ".into()));
|
||||||
|
assert_eq!(id("https://youtu.be/dQw4w9WgXcQ?si=x"), Some("dQw4w9WgXcQ".into()));
|
||||||
|
assert_eq!(id("https://m.youtube.com/shorts/dQw4w9WgXcQ"), Some("dQw4w9WgXcQ".into()));
|
||||||
|
assert_eq!(id("https://www.youtube.com/embed/dQw4w9WgXcQ"), Some("dQw4w9WgXcQ".into()));
|
||||||
|
assert_eq!(id("https://www.youtube.com/watch?v=bad\"id"), None);
|
||||||
|
assert_eq!(id("https://www.youtube.com/@channel"), None);
|
||||||
|
assert_eq!(id("https://example.com/watch?v=dQw4w9WgXcQ"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reads_a_page_head() {
|
||||||
|
let base = Url::parse("https://fishies.lol/play/").unwrap();
|
||||||
|
let html = r#"<!doctype html><html><head><title>Fallback</title>
|
||||||
|
<meta property="og:title" content="Fishies & friends">
|
||||||
|
<meta name=description content='The social aquarium'>
|
||||||
|
<meta property="og:site_name" content="Fishies">
|
||||||
|
<meta property="og:image" content="/img/card.png">
|
||||||
|
</head><body><meta property="og:title" content="not this"></body></html>"#;
|
||||||
|
let meta = parse_head(html, &base);
|
||||||
|
assert_eq!(meta.title, "Fishies & friends");
|
||||||
|
assert_eq!(meta.description, "The social aquarium");
|
||||||
|
assert_eq!(meta.site, "Fishies");
|
||||||
|
assert_eq!(meta.image.unwrap().as_str(), "https://fishies.lol/img/card.png");
|
||||||
|
let bare = parse_head("<head><title>\n Only a title \u{202e}</title><meta property='og:image' content='http://insecure.example/a.png'></head>", &base);
|
||||||
|
assert_eq!(bare.title, "Only a title");
|
||||||
|
assert_eq!(bare.image, None, "an image off the link rules is dropped");
|
||||||
|
assert_eq!(parse_head("<head><meta property='og:image' content='https://10.0.0.1/a.png'></head>", &base).image, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_is_plain_and_capped() {
|
||||||
|
assert_eq!(clean("a<b> AB &unknown; &", 50), "a<b> AB &unknown; &");
|
||||||
|
assert_eq!(clean(&"x".repeat(500), 10).chars().count(), 10);
|
||||||
|
assert_eq!(clean("\u{0}\u{7}one\ttwo\n", 50), "one two");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn images_by_magic_bytes_only() {
|
||||||
|
assert_eq!(image_mime(b"\x89PNG\r\n\x1a\nrest"), Some("image/png"));
|
||||||
|
assert_eq!(image_mime(b"\xff\xd8\xff\xe0"), Some("image/jpeg"));
|
||||||
|
assert_eq!(image_mime(b"GIF89a"), Some("image/gif"));
|
||||||
|
assert_eq!(image_mime(b"RIFF\0\0\0\0WEBPVP8 "), Some("image/webp"));
|
||||||
|
assert_eq!(image_mime(b"<svg xmlns="), None);
|
||||||
|
assert_eq!(image_mime(b"<html>"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -83,7 +83,11 @@ fn remote_capabilities_cannot_reach_the_vault() {
|
||||||
let read = |json: &str| serde_json::from_str::<serde_json::Value>(json).unwrap();
|
let read = |json: &str| serde_json::from_str::<serde_json::Value>(json).unwrap();
|
||||||
let x = read(include_str!("../capabilities/x-capture.json"));
|
let x = read(include_str!("../capabilities/x-capture.json"));
|
||||||
// Drafts popover (2026-09-27): X may only open/close it about itself and ask one yes/no hint question.
|
// Drafts popover (2026-09-27): X may only open/close it about itself and ask one yes/no hint question.
|
||||||
assert_eq!(x["permissions"], serde_json::json!(["allow-capture-draft", "allow-open-panel", "allow-drafts-picker-open", "allow-drafts-popover-dismiss", "allow-drafts-hint", "allow-keyword-alert"]));
|
// Chat+ link previews (owner-approved, 2026-10-10): one link at a time, from a chat page only.
|
||||||
|
assert_eq!(x["permissions"], serde_json::json!(["allow-capture-draft", "allow-open-panel", "allow-drafts-picker-open", "allow-drafts-popover-dismiss", "allow-drafts-hint", "allow-keyword-alert", "allow-link-preview"]));
|
||||||
|
let desktop = include_str!("../src/desktop.rs");
|
||||||
|
waybetter_host::testkit::assert_guarded(desktop, "link_preview", "policy::require_capture(webview.label(), &webview.url()");
|
||||||
|
assert!(desktop.contains("link_preview::is_chat_page(page.path())"), "link previews answer chat pages only");
|
||||||
for vault in ["allow-popover-drafts", "allow-popover-insert", "allow-popover-state", "allow-draft-list"] {
|
for vault in ["allow-popover-drafts", "allow-popover-insert", "allow-popover-state", "allow-draft-list"] {
|
||||||
assert!(!x["permissions"].as_array().unwrap().iter().any(|p| p == vault), "X tabs have {vault}");
|
assert!(!x["permissions"].as_array().unwrap().iter().any(|p| p == vault), "X tabs have {vault}");
|
||||||
}
|
}
|
||||||
|
|
@ -315,7 +319,7 @@ fn host_commands_keep_their_callers() {
|
||||||
caps.assert_shell_only(command);
|
caps.assert_shell_only(command);
|
||||||
}
|
}
|
||||||
// Tabs, about themselves only; the X-only ones are granted only on https://x.com.
|
// Tabs, about themselves only; the X-only ones are granted only on https://x.com.
|
||||||
for command in ["tab_report", "capture_draft", "open_panel", "drafts_picker_open", "drafts_popover_dismiss", "drafts_hint", "keyword_alert"] {
|
for command in ["tab_report", "capture_draft", "open_panel", "drafts_picker_open", "drafts_popover_dismiss", "drafts_hint", "keyword_alert", "link_preview"] {
|
||||||
caps.assert_tab_only(command);
|
caps.assert_tab_only(command);
|
||||||
}
|
}
|
||||||
let desktop = include_str!("../src/desktop.rs");
|
let desktop = include_str!("../src/desktop.rs");
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ export const FEATURES: readonly FeatureSpec[] = [
|
||||||
{ id: 'chat.searchLoaded', surfaces: CHAT, heavy: true, ...words('searchLoaded'), keywords: 'find search in conversation ctrl shift f messages', permissions: ['ui', 'commands'] },
|
{ id: 'chat.searchLoaded', surfaces: CHAT, heavy: true, ...words('searchLoaded'), keywords: 'find search in conversation ctrl shift f messages', permissions: ['ui', 'commands'] },
|
||||||
{ id: 'chat.pins', surfaces: CHAT, heavy: true, ...words('pins'), keywords: 'pin pinned label color chats conversations strip', permissions: ['storage', 'ui', 'commands'] },
|
{ id: 'chat.pins', surfaces: CHAT, heavy: true, ...words('pins'), keywords: 'pin pinned label color chats conversations strip', permissions: ['storage', 'ui', 'commands'] },
|
||||||
{ id: 'chat.personFilter', surfaces: CHAT, heavy: true, ...words('personFilter'), keywords: 'group chat person participant filter only from', permissions: ['storage', 'ui', 'commands'] },
|
{ id: 'chat.personFilter', surfaces: CHAT, heavy: true, ...words('personFilter'), keywords: 'group chat person participant filter only from', permissions: ['storage', 'ui', 'commands'] },
|
||||||
|
// On by default (owner, 2026-10-10): asks native code for the preview of a link on screen (link_preview.rs).
|
||||||
|
{ id: 'chat.linkPreviews', surfaces: CHAT, heavy: true, default: true, ...words('linkPreviews'), keywords: 'link preview embed youtube card unfurl', permissions: ['ui'] },
|
||||||
{ id: 'chat.keywords', surfaces: CHAT, heavy: true, settings: true, ...words('keywords'), keywords: 'highlight mute words keywords hide messages', permissions: ['storage', 'ui'] },
|
{ id: 'chat.keywords', surfaces: CHAT, heavy: true, settings: true, ...words('keywords'), keywords: 'highlight mute words keywords hide messages', permissions: ['storage', 'ui'] },
|
||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,14 @@
|
||||||
// X's chat is the one place the rest of the app stays out of (a heavy page). These modules run
|
// X's chat is the one place the rest of the app stays out of (a heavy page). These modules run
|
||||||
// there on purpose and keep it cheap: one shared watch each (child list only, one frame), looking
|
// there on purpose and keep it cheap: one shared watch each (child list only, one frame), looking
|
||||||
// only at X's message list or its conversation list. ./x.ts is what they know about X's chat.
|
// only at X's message list or its conversation list. ./x.ts is what they know about X's chat.
|
||||||
// None of them makes a request, writes or sends a message, reacts, deletes or marks anything read.
|
// None of them writes or sends a message, reacts, deletes or marks anything read. The one request is
|
||||||
|
// Link previews' (owner-approved, 2026-10-10): the link alone, to native code, which fetches its preview.
|
||||||
|
|
||||||
import type { FeatureModule } from '../../../../../waybetter/packages/runtime/src/featureModules.ts';
|
import type { FeatureModule } from '../../../../../waybetter/packages/runtime/src/featureModules.ts';
|
||||||
import type { PluginApi } from '../../inject/pluginApi.ts';
|
import type { PluginApi } from '../../inject/pluginApi.ts';
|
||||||
import { bookmarks } from './bookmarks.ts';
|
import { bookmarks } from './bookmarks.ts';
|
||||||
import { keywords } from './keywords.ts';
|
import { keywords } from './keywords.ts';
|
||||||
|
import { linkPreviews } from './linkPreviews.ts';
|
||||||
import { personFilter } from './personFilter.ts';
|
import { personFilter } from './personFilter.ts';
|
||||||
import { pins } from './pins.ts';
|
import { pins } from './pins.ts';
|
||||||
import { searchLoaded } from './searchLoaded.ts';
|
import { searchLoaded } from './searchLoaded.ts';
|
||||||
|
|
@ -21,4 +23,5 @@ export const MODULES: ReadonlyArray<FeatureModule<PluginApi>> = [
|
||||||
pins,
|
pins,
|
||||||
personFilter,
|
personFilter,
|
||||||
keywords,
|
keywords,
|
||||||
|
linkPreviews,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
229
apps/desktop/src/areas/chat/linkPreviews.ts
Normal file
229
apps/desktop/src/areas/chat/linkPreviews.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
// Chat+ · Link previews. X's web chat shows a link as a bare card (an icon and the domain). For a
|
||||||
|
// link in a message that is on screen, this asks native code (`link_preview`, one link at a time;
|
||||||
|
// src-tauri/src/link_preview.rs) for its preview and shows it under X's card: the page's title,
|
||||||
|
// description and image, or for YouTube the video's thumbnail with a play button that opens
|
||||||
|
// YouTube's own player in place (www.youtube.com is in X's own frame-src).
|
||||||
|
//
|
||||||
|
// Owner-approved network use (2026-10-10). Only the link leaves the page: never the message text,
|
||||||
|
// the sender or the conversation. Nothing is stored: answers are kept in memory for this page only.
|
||||||
|
// X's card is hidden while a preview stands in for it and comes back when the feature stops.
|
||||||
|
|
||||||
|
import type { FeatureContext, FeatureModule } from '../../../../../waybetter/packages/runtime/src/featureModules.ts';
|
||||||
|
import type { PluginApi } from '../../inject/pluginApi.ts';
|
||||||
|
import { clearAttribute } from './ui.ts';
|
||||||
|
import { CONVERSATION_PROBES, CHAT_PAGE_PROBE, chatRoot, chatSelectors, renderedRows } from './x.ts';
|
||||||
|
|
||||||
|
export type LinkPreview = { url: string; site: string; title: string; description: string; image?: string; youtube?: string };
|
||||||
|
type Invoke = (command: 'link_preview', args: { url: string }) => Promise<unknown>;
|
||||||
|
|
||||||
|
let bridge: Invoke | null = null;
|
||||||
|
/** The runtime hands over its native bridge (src/inject/entry.tsx); without one the feature shows nothing. */
|
||||||
|
export function setLinkPreviewBridge(invoke: Invoke | null | undefined): void { bridge = invoke ?? null; }
|
||||||
|
|
||||||
|
const X_HOSTS = /(^|\.)(x\.com|twitter\.com|t\.co|twimg\.com|x\.co)$/i;
|
||||||
|
|
||||||
|
/** A link worth a preview: plain https, a public-looking host name, not X's own. */
|
||||||
|
export function previewable(href: string | null | undefined): string | null {
|
||||||
|
if (!href) return null;
|
||||||
|
let url: URL;
|
||||||
|
try { url = new URL(href); } catch { return null; }
|
||||||
|
if (url.protocol !== 'https:' || url.username || url.password || (url.port && url.port !== '443')) return null;
|
||||||
|
const host = url.hostname.toLowerCase();
|
||||||
|
if (!host.includes('.') || /^[\d.]+$/.test(host) || host.startsWith('[') || X_HOSTS.test(host)) return null;
|
||||||
|
return url.href;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A native answer as a preview, or null: every field is checked again (it becomes page text). */
|
||||||
|
export function asPreview(value: unknown): LinkPreview | null {
|
||||||
|
if (!value || typeof value !== 'object') return null;
|
||||||
|
const v = value as Record<string, unknown>;
|
||||||
|
const str = (key: string, max: number) => (typeof v[key] === 'string' ? (v[key] as string).slice(0, max) : '');
|
||||||
|
const url = previewable(str('url', 2048));
|
||||||
|
if (!url) return null;
|
||||||
|
const image = str('image', 3_000_000);
|
||||||
|
const youtube = str('youtube', 11);
|
||||||
|
const preview: LinkPreview = { url, site: str('site', 80), title: str('title', 200), description: str('description', 300) };
|
||||||
|
if (/^data:image\/(png|jpeg|gif|webp);base64,[A-Za-z0-9+/=]+$/.test(image)) preview.image = image;
|
||||||
|
if (/^[A-Za-z0-9_-]{11}$/.test(youtube)) preview.youtube = youtube;
|
||||||
|
return preview.title || preview.image || preview.youtube ? preview : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CARD = 'bt-chat-lp';
|
||||||
|
const HIDE = 'data-bt-chat-lp-hidden';
|
||||||
|
const FOR = 'data-bt-chat-lp-for';
|
||||||
|
const ACCENT = 'var(--wb-accent, rgb(29, 155, 240))';
|
||||||
|
|
||||||
|
const CSS = `
|
||||||
|
[${HIDE}] { display: none !important; }
|
||||||
|
.${CARD} { display: block; box-sizing: border-box; width: 340px; max-width: 100%; margin: 6px 0 2px; overflow: hidden; border-radius: 16px; border: 1px solid rgba(127,127,127,.28); background: rgba(127,127,127,.10); color: inherit; text-decoration: none; font: 400 14px/1.35 var(--wb-font, system-ui, sans-serif); cursor: pointer; }
|
||||||
|
.${CARD}:hover { background: rgba(127,127,127,.16); }
|
||||||
|
.${CARD}:focus-visible { outline: 2px solid ${ACCENT}; outline-offset: 2px; }
|
||||||
|
.${CARD}-media { position: relative; display: block; width: 100%; aspect-ratio: 1.91 / 1; background: rgba(0,0,0,.35); }
|
||||||
|
.${CARD}-media.is-video { aspect-ratio: 16 / 9; }
|
||||||
|
.${CARD}-media img, .${CARD}-media iframe { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; border: 0; display: block; }
|
||||||
|
.${CARD}-play { position: absolute; left: 50%; top: 50%; width: 68px; height: 48px; transform: translate(-50%, -50%); border: 0; border-radius: 14px; background: rgba(0,0,0,.72); color: #fff; display: flex; align-items: center; justify-content: center; cursor: pointer; }
|
||||||
|
.${CARD}-play:hover { background: #f00; }
|
||||||
|
.${CARD}-play svg { width: 26px; height: 26px; fill: currentColor; }
|
||||||
|
.${CARD}-body { display: block; padding: 10px 14px 12px; }
|
||||||
|
.${CARD}-site { display: block; font-size: 12.5px; opacity: .65; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.${CARD}-title { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-top: 2px; font-weight: 700; }
|
||||||
|
.${CARD}-desc { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-top: 2px; font-size: 13.5px; opacity: .78; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
/** The links of one message: its own anchors to the outside, first one only per address. */
|
||||||
|
export function rowLinks(row: Element): HTMLAnchorElement[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: HTMLAnchorElement[] = [];
|
||||||
|
for (const anchor of Array.from(row.querySelectorAll<HTMLAnchorElement>('a[href]'))) {
|
||||||
|
if (anchor.closest(`.${CARD}`)) continue;
|
||||||
|
const url = previewable(anchor.href || anchor.getAttribute('href'));
|
||||||
|
if (!url || seen.has(url)) continue;
|
||||||
|
seen.add(url);
|
||||||
|
out.push(anchor);
|
||||||
|
}
|
||||||
|
return out.slice(0, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* X's bare link card (a message that is only a link: an icon and the host name, no picture). A link
|
||||||
|
* inside a message's text shows its path too and is never hidden.
|
||||||
|
*/
|
||||||
|
export function isBareCard(anchor: Element): boolean {
|
||||||
|
if (anchor.querySelector('img, video')) return false;
|
||||||
|
const text = (anchor.textContent ?? '').trim().replace(/^www\./i, '');
|
||||||
|
return /^[a-z0-9-]+(\.[a-z0-9-]+)*\.[a-z]{2,}$/i.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a link's preview goes: after the message's bubble (X's `[role="article"]`), or after X's own
|
||||||
|
* link card when the link is one; null when the anchor is in neither (nothing is placed).
|
||||||
|
*/
|
||||||
|
export function cardPlace(anchor: Element): Element | null {
|
||||||
|
const bubble = anchor.closest('[role="article"]') ?? anchor.closest('[data-testid^="message-text-"]');
|
||||||
|
return bubble?.parentElement ? bubble : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLAY = 'M8 5.5v13l11-6.5z';
|
||||||
|
const SVG = 'http://www.w3.org/2000/svg';
|
||||||
|
|
||||||
|
function el<K extends keyof HTMLElementTagNameMap>(doc: Document, tag: K, className: string, text?: string): HTMLElementTagNameMap[K] {
|
||||||
|
const node = doc.createElement(tag);
|
||||||
|
node.className = className;
|
||||||
|
if (text) node.textContent = text;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The preview card for `preview` (text only through textContent; URLs re-checked). */
|
||||||
|
export function renderCard(doc: Document, preview: LinkPreview, words: { play: string; open: string }): HTMLAnchorElement {
|
||||||
|
const card = el(doc, 'a', CARD);
|
||||||
|
card.href = preview.url;
|
||||||
|
card.target = '_blank';
|
||||||
|
card.rel = 'noopener noreferrer';
|
||||||
|
card.setAttribute(FOR, preview.url);
|
||||||
|
card.title = words.open;
|
||||||
|
if (preview.youtube) {
|
||||||
|
const media = el(doc, 'span', `${CARD}-media is-video`);
|
||||||
|
const thumb = el(doc, 'img', '');
|
||||||
|
thumb.src = preview.image ?? `https://i.ytimg.com/vi/${preview.youtube}/hqdefault.jpg`;
|
||||||
|
thumb.alt = '';
|
||||||
|
thumb.referrerPolicy = 'no-referrer';
|
||||||
|
const play = el(doc, 'button', `${CARD}-play`);
|
||||||
|
play.type = 'button';
|
||||||
|
play.setAttribute('aria-label', words.play);
|
||||||
|
const svg = doc.createElementNS(SVG, 'svg');
|
||||||
|
svg.setAttribute('viewBox', '0 0 24 24');
|
||||||
|
const path = doc.createElementNS(SVG, 'path');
|
||||||
|
path.setAttribute('d', PLAY);
|
||||||
|
svg.append(path);
|
||||||
|
play.append(svg);
|
||||||
|
play.addEventListener('click', event => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const frame = el(doc, 'iframe', '');
|
||||||
|
frame.src = `https://www.youtube.com/embed/${preview.youtube}?autoplay=1&rel=0`;
|
||||||
|
frame.allow = 'autoplay; encrypted-media; picture-in-picture; fullscreen';
|
||||||
|
frame.allowFullscreen = true;
|
||||||
|
frame.referrerPolicy = 'strict-origin-when-cross-origin';
|
||||||
|
frame.title = preview.title || 'YouTube';
|
||||||
|
media.replaceChildren(frame);
|
||||||
|
card.removeAttribute('href');
|
||||||
|
card.style.cursor = 'default';
|
||||||
|
});
|
||||||
|
media.append(thumb, play);
|
||||||
|
card.append(media);
|
||||||
|
} else if (preview.image) {
|
||||||
|
const media = el(doc, 'span', `${CARD}-media`);
|
||||||
|
const image = el(doc, 'img', '');
|
||||||
|
image.src = preview.image;
|
||||||
|
image.alt = '';
|
||||||
|
image.decoding = 'async';
|
||||||
|
media.append(image);
|
||||||
|
card.append(media);
|
||||||
|
}
|
||||||
|
const body = el(doc, 'span', `${CARD}-body`);
|
||||||
|
body.append(el(doc, 'span', `${CARD}-site`, preview.site));
|
||||||
|
if (preview.title) body.append(el(doc, 'span', `${CARD}-title`, preview.title));
|
||||||
|
if (preview.description) body.append(el(doc, 'span', `${CARD}-desc`, preview.description));
|
||||||
|
card.append(body);
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Ctx = FeatureContext<PluginApi>;
|
||||||
|
|
||||||
|
export const linkPreviews: FeatureModule<PluginApi> = {
|
||||||
|
id: 'chat.linkPreviews',
|
||||||
|
probes: { ...CONVERSATION_PROBES, ...CHAT_PAGE_PROBE },
|
||||||
|
start(ctx: Ctx) {
|
||||||
|
const { win } = ctx;
|
||||||
|
const doc = win.document;
|
||||||
|
const sel = chatSelectors(ctx, CONVERSATION_PROBES);
|
||||||
|
/** Answers for this page only (null: no preview for that link). */
|
||||||
|
const answers = new Map<string, LinkPreview | null>();
|
||||||
|
const pending = new Set<string>();
|
||||||
|
let stopped = false;
|
||||||
|
const words = () => ({ play: ctx.text('play'), open: ctx.text('open') });
|
||||||
|
|
||||||
|
const ask = (url: string) => {
|
||||||
|
if (!bridge || pending.has(url) || answers.has(url)) return;
|
||||||
|
pending.add(url);
|
||||||
|
bridge('link_preview', { url })
|
||||||
|
.then(value => answers.set(url, asPreview(value)), () => answers.set(url, null))
|
||||||
|
.finally(() => { pending.delete(url); if (!stopped) watch.schedule(); });
|
||||||
|
};
|
||||||
|
|
||||||
|
const scan = () => {
|
||||||
|
for (const row of renderedRows(chatRoot(doc), sel)) {
|
||||||
|
let after: Element | null = null;
|
||||||
|
for (const anchor of rowLinks(row)) {
|
||||||
|
const url = previewable(anchor.href || anchor.getAttribute('href'))!;
|
||||||
|
const answer = answers.get(url);
|
||||||
|
if (answer === undefined) { ask(url); continue; }
|
||||||
|
if (!answer) { if (anchor.hasAttribute(HIDE)) anchor.removeAttribute(HIDE); continue; }
|
||||||
|
// Under the message's bubble (live 2026-10-10: X keeps a link in the text as a plain anchor),
|
||||||
|
// in the bubble's own column, so it lines up with the message on either side.
|
||||||
|
const bubble = cardPlace(anchor);
|
||||||
|
if (!bubble) continue;
|
||||||
|
const column = bubble.parentElement!;
|
||||||
|
const existing = Array.from(column.children).find(node => node.getAttribute(FOR) === url);
|
||||||
|
const card = existing ?? renderCard(doc, answer, words());
|
||||||
|
const anchorAfter: Element = after ?? bubble;
|
||||||
|
if (anchorAfter.nextElementSibling !== card) anchorAfter.after(card);
|
||||||
|
after = card;
|
||||||
|
if (isBareCard(anchor) && !anchor.hasAttribute(HIDE)) anchor.setAttribute(HIDE, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ctx.css(CSS);
|
||||||
|
const watch = ctx.watch(scan);
|
||||||
|
ctx.onRouteChange(() => watch.schedule());
|
||||||
|
ctx.defer(() => {
|
||||||
|
stopped = true;
|
||||||
|
for (const place of new Set<ParentNode>([doc, chatRoot(doc)])) {
|
||||||
|
for (const card of Array.from(place.querySelectorAll(`.${CARD}`))) card.remove();
|
||||||
|
clearAttribute(place, HIDE);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
watch.schedule();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
// flag-controlled parts and the palette command report; panelButton.ts keeps the way into the
|
// flag-controlled parts and the palette command report; panelButton.ts keeps the way into the
|
||||||
// panel on the page). This file is X's: which parts exist, X's own members (alerts, fixes)
|
// panel on the page). This file is X's: which parts exist, X's own members (alerts, fixes)
|
||||||
// and how the "wayBetter" item joins X's navigation.
|
// and how the "wayBetter" item joins X's navigation.
|
||||||
|
import { setLinkPreviewBridge } from '../areas/chat/linkPreviews.ts';
|
||||||
import { installFeatures, type FeatureConfig } from './features';
|
import { installFeatures, type FeatureConfig } from './features';
|
||||||
import { ALL_MODULES } from '../areas/modules';
|
import { ALL_MODULES } from '../areas/modules';
|
||||||
import { surfacesOf } from '../areas/surfaces';
|
import { surfacesOf } from '../areas/surfaces';
|
||||||
|
|
@ -88,6 +89,8 @@ function install() {
|
||||||
// This runtime only ships inside the app's X tabs; automated test browsers have no bridge, and
|
// This runtime only ships inside the app's X tabs; automated test browsers have no bridge, and
|
||||||
// the features that need it stay off there.
|
// the features that need it stay off there.
|
||||||
const invoke = pageInvoke(window, LABEL);
|
const invoke = pageInvoke(window, LABEL);
|
||||||
|
// Chat+ link previews ask native code for one link's preview (the only feature module with a bridge).
|
||||||
|
setLinkPreviewBridge(invoke as Parameters<typeof setLinkPreviewBridge>[0]);
|
||||||
|
|
||||||
// Flag-controlled fixes and tweaks: installed on enable, disposed on disable. The first ones wrap
|
// Flag-controlled fixes and tweaks: installed on enable, disposed on disable. The first ones wrap
|
||||||
// fetch like the mutation gate: installed at document start, before X's bundle runs.
|
// fetch like the mutation gate: installed at document start, before X's bundle runs.
|
||||||
|
|
|
||||||
|
|
@ -61,11 +61,11 @@ const pace = (chat: FakeChat) => ({ sleep: (ms: number) => new Promise<void>(res
|
||||||
const star = (row: El) => row.querySelector('.bt-chat-star')!;
|
const star = (row: El) => row.querySelector('.bt-chat-star')!;
|
||||||
const group = (chat: FakeChat) => chat.conversations.get(GROUP)!.history;
|
const group = (chat: FakeChat) => chat.conversations.get(GROUP)!.history;
|
||||||
|
|
||||||
test('Chat+: five features, each its own flag, all off by default, all allowed on X\'s chat pages', () => {
|
test('Chat+: six features, each its own flag, off by default but link previews (owner, 2026-10-10), all allowed on X\'s chat pages', () => {
|
||||||
assert.deepEqual(FEATURES.map(feature => feature.id), ['chat.bookmarks', 'chat.searchLoaded', 'chat.pins', 'chat.personFilter', 'chat.keywords']);
|
assert.deepEqual(FEATURES.map(feature => feature.id), ['chat.bookmarks', 'chat.searchLoaded', 'chat.pins', 'chat.personFilter', 'chat.linkPreviews', 'chat.keywords']);
|
||||||
assert.deepEqual(MODULES.map(module => module.id).sort(), FEATURES.map(feature => feature.id).sort());
|
assert.deepEqual(MODULES.map(module => module.id).sort(), FEATURES.map(feature => feature.id).sort());
|
||||||
for (const feature of FEATURES) {
|
for (const feature of FEATURES) {
|
||||||
assert.equal(feature.default, false, feature.id);
|
assert.equal(feature.default, feature.id === 'chat.linkPreviews', feature.id);
|
||||||
assert.equal(feature.heavy, true, `${feature.id} runs on the heavy chat pages`);
|
assert.equal(feature.heavy, true, `${feature.id} runs on the heavy chat pages`);
|
||||||
assert.ok(feature.permissions && !feature.permissions.includes('network'), `${feature.id}: no network`);
|
assert.ok(feature.permissions && !feature.permissions.includes('network'), `${feature.id}: no network`);
|
||||||
}
|
}
|
||||||
|
|
@ -175,7 +175,7 @@ test('starred messages: stored in full, valid ones only, each once; the list fil
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Starred messages: a star on every rendered row; a click stores exactly that message, in full; off leaves nothing', () => {
|
test('Starred messages: a star on every rendered row; a click stores exactly that message, in full; off leaves nothing', () => {
|
||||||
const { env, chat, stored } = page(`/i/chat/${GROUP}`, { 'chat.bookmarks': true });
|
const { env, chat, stored } = page(`/i/chat/${GROUP}`, { 'chat.bookmarks': true, 'chat.linkPreviews': false });
|
||||||
assert.deepEqual(env.registry.active(), ['chat.bookmarks']);
|
assert.deepEqual(env.registry.active(), ['chat.bookmarks']);
|
||||||
const all = group(chat);
|
const all = group(chat);
|
||||||
const rows = chat.rendered();
|
const rows = chat.rendered();
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,8 @@ const DEFAULT_ON = [
|
||||||
'logoBrush', 'twitterWording', 'singleSplitNav',
|
'logoBrush', 'twitterWording', 'singleSplitNav',
|
||||||
'fixLikeFollow', 'fixScrollRestore', 'fixTimelineJump', 'fixHomeTab', 'fixNotificationBadge', 'fixNewPostsPill', 'fixMediaUpload', 'followingChronological', 'fixChatButton', 'composerRestorePrompt',
|
'fixLikeFollow', 'fixScrollRestore', 'fixTimelineJump', 'fixHomeTab', 'fixNotificationBadge', 'fixNewPostsPill', 'fixMediaUpload', 'followingChronological', 'fixChatButton', 'composerRestorePrompt',
|
||||||
'composerDrafts', 'hidePromotedPosts', 'mediaDownload', 'rememberVideoVolume', 'searchOperators', 'notifications.keywordAlerts',
|
'composerDrafts', 'hidePromotedPosts', 'mediaDownload', 'rememberVideoVolume', 'searchOperators', 'notifications.keywordAlerts',
|
||||||
|
// On by default by the owner's choice (2026-10-10): Chat+ link previews.
|
||||||
|
'chat.linkPreviews',
|
||||||
];
|
];
|
||||||
/** What Advanced adds: the calm features that came with the area plugins (they add something; none hides or changes X). */
|
/** What Advanced adds: the calm features that came with the area plugins (they add something; none hides or changes X). */
|
||||||
const ADVANCED_ADDS = [
|
const ADVANCED_ADDS = [
|
||||||
|
|
@ -166,7 +168,7 @@ test('modes: the exact flags of Basic, Advanced and Power User; an existing setu
|
||||||
assert.deepEqual(on(defaults), [...DEFAULT_ON].sort());
|
assert.deepEqual(on(defaults), [...DEFAULT_ON].sort());
|
||||||
assert.deepEqual(running(null), [...DEFAULT_ON].sort());
|
assert.deepEqual(running(null), [...DEFAULT_ON].sort());
|
||||||
for (const [flag, value] of Object.entries(FEATURE_DEFAULTS)) assert.equal(defaults[flag], value, flag);
|
for (const [flag, value] of Object.entries(FEATURE_DEFAULTS)) assert.equal(defaults[flag], value, flag);
|
||||||
for (const feature of X_FEATURES) if (!(feature.id in FEATURE_DEFAULTS) && feature.id !== 'notifications.keywordAlerts' && feature.backing !== 'plugin') assert.equal(defaults[feature.id], false, `${feature.id} is new: off by default`);
|
for (const feature of X_FEATURES) if (!(feature.id in FEATURE_DEFAULTS) && feature.id !== 'notifications.keywordAlerts' && feature.id !== 'chat.linkPreviews' && feature.backing !== 'plugin') assert.equal(defaults[feature.id], false, `${feature.id} is new: off by default`);
|
||||||
for (const row of supersededFiles(X_TABLE)) assert.equal(defaults[row.flag], false, `${row.flag}: what came from an opt-in plugin stays off`);
|
for (const row of supersededFiles(X_TABLE)) assert.equal(defaults[row.flag], false, `${row.flag}: what came from an opt-in plugin stays off`);
|
||||||
|
|
||||||
// Basic: every area off (so everything an area owns is off), no logo or wording; the fixes and split-view navigation stay.
|
// Basic: every area off (so everything an area owns is off), no logo or wording; the fixes and split-view navigation stay.
|
||||||
|
|
|
||||||
48
apps/desktop/tests/chat-link-previews.test.ts
Normal file
48
apps/desktop/tests/chat-link-previews.test.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
// Chat+ link previews (src/areas/chat/linkPreviews.ts; native side src-tauri/src/link_preview.rs):
|
||||||
|
// which links are asked about, how a native answer is checked again before it becomes page text,
|
||||||
|
// and which of X's cards a preview stands in for. Nothing here touches the network or x.com.
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { asPreview, isBareCard, previewable } from '../src/areas/chat/linkPreviews.ts';
|
||||||
|
|
||||||
|
test('link previews: only plain https links to hosts that are not X\'s own', () => {
|
||||||
|
assert.equal(previewable('https://www.youtube.com/watch?v=dQw4w9WgXcQ'), 'https://www.youtube.com/watch?v=dQw4w9WgXcQ');
|
||||||
|
assert.equal(previewable('https://fishies.lol/'), 'https://fishies.lol/');
|
||||||
|
for (const bad of [
|
||||||
|
'http://example.com/', 'https://user:pw@example.com/', 'https://example.com:8443/', 'https://192.168.0.1/', 'https://[::1]/',
|
||||||
|
'https://intranet/', 'https://x.com/a/status/1', 'https://t.co/abc', 'https://pbs.twimg.com/media/a.jpg', 'javascript:alert(1)', '/i/chat/1-2', '', null,
|
||||||
|
]) assert.equal(previewable(bad), null, String(bad));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('link previews: a native answer is checked again: plain fields, capped, data: images of four types, an 11-character video id', () => {
|
||||||
|
const good = asPreview({ url: 'https://fishies.lol/', site: 'Fishies', title: 'The social aquarium', description: 'x'.repeat(400), image: 'data:image/png;base64,iVBORw0KGgo=' });
|
||||||
|
assert.ok(good);
|
||||||
|
assert.equal(good.description.length, 300);
|
||||||
|
assert.equal(good.image, 'data:image/png;base64,iVBORw0KGgo=');
|
||||||
|
assert.equal(asPreview({ url: 'https://a.example/', title: 't', image: 'data:image/svg+xml;base64,PHN2Zz4=' })?.image, undefined, 'no SVG');
|
||||||
|
assert.equal(asPreview({ url: 'https://a.example/', title: 't', image: 'https://tracker.example/p.gif' })?.image, undefined, 'no remote image');
|
||||||
|
assert.equal(asPreview({ url: 'https://youtu.be/x', title: 't', youtube: 'dQw4w9WgXcQ' })?.youtube, 'dQw4w9WgXcQ');
|
||||||
|
assert.equal(asPreview({ url: 'https://youtu.be/x', title: 't', youtube: 'a"onload="x' })?.youtube, undefined);
|
||||||
|
assert.equal(asPreview({ url: 'javascript:alert(1)', title: 't' }), null, 'the link itself is re-checked');
|
||||||
|
assert.equal(asPreview({ url: 'https://a.example/', title: '' }), null, 'nothing to show, no card');
|
||||||
|
assert.equal(asPreview('nope'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('link previews: X\'s bare card (a host name and an icon) is replaced; a card with its own picture or a sentence is not', () => {
|
||||||
|
const anchor = (text: string, media = false) => ({ textContent: text, querySelector: () => (media ? {} : null) }) as unknown as Element;
|
||||||
|
assert.equal(isBareCard(anchor('www.youtube.com')), true);
|
||||||
|
assert.equal(isBareCard(anchor('fishies.lol')), true);
|
||||||
|
assert.equal(isBareCard(anchor('www.youtube.com', true)), false);
|
||||||
|
assert.equal(isBareCard(anchor('watch this video')), false);
|
||||||
|
assert.equal(isBareCard(anchor('www.youtube.com/watch?v=jNQXAC9IVRw')), false, 'a link in the text (live 2026-10-10) stays');
|
||||||
|
assert.equal(isBareCard(anchor('')), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('link previews: page text only through textContent, the player only on YouTube\'s host X allows, no request from the page', () => {
|
||||||
|
const source = readFileSync(new URL('../src/areas/chat/linkPreviews.ts', import.meta.url), 'utf8');
|
||||||
|
assert.doesNotMatch(source, /innerHTML|insertAdjacentHTML|outerHTML|document\.write/);
|
||||||
|
assert.doesNotMatch(source, /\bfetch\(|XMLHttpRequest|WebSocket|sendBeacon/);
|
||||||
|
for (const match of source.matchAll(/https:\/\/([a-z0-9.-]+)\//g)) assert.ok(['i.ytimg.com', 'www.youtube.com'].includes(match[1]!), match[1]);
|
||||||
|
assert.match(source, /rel = 'noopener noreferrer'/);
|
||||||
|
});
|
||||||
|
|
@ -180,6 +180,7 @@ Commit path-limited to your own files. `src-tauri/src/areas.rs` is committed tog
|
||||||
Owner decisions that bind the areas:
|
Owner decisions that bind the areas:
|
||||||
|
|
||||||
- **Hide Blue Checks (Reader+ `reader.collapseVerified`, Profile+)**: never collapse a post by the owner of the profile page being visited (every profile sub-route, handle compared case-insensitively), nor the focal post of an opened post page. A post they reposted from someone else follows the normal rule. An author X has not described yet is never collapsed. Leaving the profile re-checks the posts on screen. The `hideVerifiedBadges` tweak keeps the badge in the visited profile's header.
|
- **Hide Blue Checks (Reader+ `reader.collapseVerified`, Profile+)**: never collapse a post by the owner of the profile page being visited (every profile sub-route, handle compared case-insensitively), nor the focal post of an opened post page. A post they reposted from someone else follows the normal rule. An author X has not described yet is never collapsed. Leaving the profile re-checks the posts on screen. The `hideVerifiedBadges` tweak keeps the badge in the visited profile's header.
|
||||||
|
- **Link previews (Chat+, owner-approved 2026-10-10, on by default)**: for a link in a chat message on screen, the page asks native code (`link_preview`, X tabs on X's chat pages only) for that one link's preview. Native code fetches it itself (`src-tauri/src/link_preview.rs`): https only, a public host name (every resolved address public, the connection pinned to them), never X's own hosts, no cookies or referrer, redirects re-checked (at most 4), a page head of at most 512 KB, an image of at most 1.5 MB by its magic bytes (PNG/JPEG/GIF/WebP) returned as a `data:` URL, at most 30 fresh fetches a minute, a memory-only cache. Only the link leaves the page, never the message, its sender or the conversation. YouTube's player (a www.youtube.com frame, which X's CSP allows) loads only when the user presses play.
|
||||||
- **Starred DMs (Chat+)**: a message the user explicitly stars may be stored locally in full (message id, conversation id, sender, text, time, media references). Never in sync. In a backup only when the owner ticks it (not built yet: Export/Import as JSON stands in). No passive collection of any kind.
|
- **Starred DMs (Chat+)**: a message the user explicitly stars may be stored locally in full (message id, conversation id, sender, text, time, media references). Never in sync. In a backup only when the owner ticks it (not built yet: Export/Import as JSON stands in). No passive collection of any kind.
|
||||||
- **Launch scope**: the ports of what exists and the 29 "must" items of the plan. The 35 "nice" and 7 "later" items come after launch.
|
- **Launch scope**: the ports of what exists and the 29 "must" items of the plan. The 35 "nice" and 7 "later" items come after launch.
|
||||||
- **Modes** (first run; an existing setup keeps what it has):
|
- **Modes** (first run; an existing setup keeps what it has):
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue