diff --git a/apps/blog/features/post-rendering/lib/renderer.ts b/apps/blog/features/post-rendering/lib/renderer.ts index 16c7671fcbf1b282dcd0651408ce21cb292fbe8b..9825db04e25d29f48e4f655725505cfee2c79ae0 100644 --- a/apps/blog/features/post-rendering/lib/renderer.ts +++ b/apps/blog/features/post-rendering/lib/renderer.ts @@ -1,4 +1,4 @@ -import { DefaultRenderer, TablePlugin, TwitterPlugin } from '@hive/renderer'; +import { DefaultRenderer, TablePlugin } from '@hive/renderer'; import { getDoubleSize, proxifyImageUrl } from '@ui/lib/old-profixy'; import imageUserBlocklist from '@hive/ui/config/lists/image-user-blocklist'; @@ -20,8 +20,8 @@ const renderDefaultOptions = { ipfsPrefix: '', assetsWidth: 640, assetsHeight: 480, - // Note: Instagram uses iframe-only via InstagramEmbedder (no external scripts needed) - plugins: [new TwitterPlugin(), new TablePlugin()], + // Note: Instagram and Twitter use iframe-only via embedders (no external scripts needed) + plugins: [new TablePlugin()], imageProxyFn: (url: string) => getDoubleSize(proxifyImageUrl(url, true).replace(/ /g, '%20')), usertagUrlFn: (account: string) => (basePath ? `${basePath}/@${account}` : `/@${account}`), hashtagUrlFn: (hashtag: string) => (basePath ? `${basePath}/trending/${hashtag}` : `/trending/${hashtag}`), diff --git a/packages/renderer/src/renderers/default/embedder/AssetEmbedder.ts b/packages/renderer/src/renderers/default/embedder/AssetEmbedder.ts index c77e086ea92dbc151224f607ca619df07153eae5..5e06885403d3da9f699387ef701bc272d8bdfbc0 100644 --- a/packages/renderer/src/renderers/default/embedder/AssetEmbedder.ts +++ b/packages/renderer/src/renderers/default/embedder/AssetEmbedder.ts @@ -5,6 +5,7 @@ import {InstagramEmbedder} from './embedders/InstagramEmbedder'; import {SpotifyEmbedder} from './embedders/SpotifyEmbedder'; import {ThreeSpeakEmbedder} from './embedders/ThreeSpeakEmbedder'; import {TwitchEmbedder} from './embedders/TwitchEmbedder'; +import {TwitterEmbedder} from './embedders/TwitterEmbedder'; import {VimeoEmbedder} from './embedders/VimeoEmbedder'; import {YoutubeEmbedder} from './embedders/YoutubeEmbedder'; @@ -24,7 +25,8 @@ export class AssetEmbedder { new TwitchEmbedder(options), new SpotifyEmbedder(), new ThreeSpeakEmbedder(), - new InstagramEmbedder() + new InstagramEmbedder(), + new TwitterEmbedder() ]; } diff --git a/packages/renderer/src/renderers/default/embedder/embedders/TwitterEmbedder.test.ts b/packages/renderer/src/renderers/default/embedder/embedders/TwitterEmbedder.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..46485a3caf175e388a9651831477bd2471da3fc0 --- /dev/null +++ b/packages/renderer/src/renderers/default/embedder/embedders/TwitterEmbedder.test.ts @@ -0,0 +1,82 @@ +import {TwitterEmbedder} from './TwitterEmbedder'; + +describe('TwitterEmbedder', () => { + describe('getEmbedMetadata', () => { + const validTwitterUrls = [ + 'https://twitter.com/username/status/1234567890123456789', + 'https://www.twitter.com/username/status/1234567890123456789', + 'https://twitter.com/user_name/status/1234567890123456789', + 'http://twitter.com/username/status/1234567890123456789' + ]; + + validTwitterUrls.forEach((url) => { + it(`should return correct metadata for Twitter URL: ${url}`, () => { + const embedder = new TwitterEmbedder(); + const result = embedder.getEmbedMetadata({data: url} as HTMLObjectElement); + expect(result).toBeDefined(); + expect(result?.id).toBe('1234567890123456789'); + expect(result?.url).toContain('/status/1234567890123456789'); + }); + }); + + const validXUrls = [ + 'https://x.com/username/status/9876543210987654321', + 'https://www.x.com/username/status/9876543210987654321', + 'https://x.com/user_name/status/9876543210987654321', + 'http://x.com/username/status/9876543210987654321' + ]; + + validXUrls.forEach((url) => { + it(`should return correct metadata for X.com URL: ${url}`, () => { + const embedder = new TwitterEmbedder(); + const result = embedder.getEmbedMetadata({data: url} as HTMLObjectElement); + expect(result).toBeDefined(); + expect(result?.id).toBe('9876543210987654321'); + expect(result?.url).toContain('/status/9876543210987654321'); + }); + }); + + const invalidUrls = [ + 'https://twitter.com/', + 'https://twitter.com/username', + 'https://twitter.com/username/status/', + 'https://twitter.com/username/status/abc', + 'https://facebook.com/user/status/1234567890', + 'https://twitter.com/i/events/1234567890', + 'not a url' + ]; + + invalidUrls.forEach((url) => { + it(`should return undefined for invalid URL: ${url}`, () => { + const embedder = new TwitterEmbedder(); + const result = embedder.getEmbedMetadata({data: url} as HTMLObjectElement); + expect(result).toBeUndefined(); + }); + }); + }); + + describe('processEmbed', () => { + it('should generate correct iframe HTML', () => { + const embedder = new TwitterEmbedder(); + const result = embedder.processEmbed('1234567890123456789', {width: 550, height: 400}); + expect(result).toBe( + '
' + ); + }); + + it('should always use platform.twitter.com regardless of input domain', () => { + const embedder = new TwitterEmbedder(); + // Even if the original URL was x.com, the embed uses platform.twitter.com + const result = embedder.processEmbed('9876543210987654321', {width: 640, height: 480}); + expect(result).toContain('platform.twitter.com'); + expect(result).not.toContain('x.com'); + }); + }); + + describe('type', () => { + it('should have correct type identifier', () => { + const embedder = new TwitterEmbedder(); + expect(embedder.type).toBe('twitter'); + }); + }); +}); diff --git a/packages/renderer/src/renderers/default/embedder/embedders/TwitterEmbedder.ts b/packages/renderer/src/renderers/default/embedder/embedders/TwitterEmbedder.ts new file mode 100644 index 0000000000000000000000000000000000000000..dca8e6fa20f776f3ce553513ac747902ee3f57b9 --- /dev/null +++ b/packages/renderer/src/renderers/default/embedder/embedders/TwitterEmbedder.ts @@ -0,0 +1,48 @@ +import {Log} from '../../../../Log'; +import {AbstractEmbedder, EmbedMetadata} from './AbstractEmbedder'; + +/** + * Embedder for Twitter/X posts. + * Converts Twitter/X URLs to iframe embeds without requiring external scripts. + * + * Supported URL formats: + * - https://twitter.com/username/status/TWEET_ID + * - https://x.com/username/status/TWEET_ID + * - https://www.twitter.com/username/status/TWEET_ID + * - https://www.x.com/username/status/TWEET_ID + * + * Note: Both twitter.com and x.com are supported to handle the rebrand. + * All embeds use platform.twitter.com which remains the stable embed domain. + */ +export class TwitterEmbedder extends AbstractEmbedder { + public type = 'twitter'; + + /** + * Matches Twitter/X status URLs. + * Tweet IDs are numeric, typically 19 digits (allow 1-20 for safety). + */ + private static readonly linkRegex = /https?:\/\/(?:www\.)?(twitter|x)\.com\/(?:\w+)\/status\/(\d{1,20})/i; + + public getEmbedMetadata(input: string | HTMLObjectElement): EmbedMetadata | undefined { + const data = typeof input === 'string' ? input : input.data; + try { + const match = data.match(TwitterEmbedder.linkRegex); + if (match && match[2]) { + const id = match[2]; + return { + id, + url: match[0] + }; + } + } catch (error) { + Log.log().error(error); + } + return undefined; + } + + public processEmbed(id: string, size: {width: number; height: number}): string { + // Use platform.twitter.com which is the stable embed domain (not affected by x.com rebrand) + const embedUrl = `https://platform.twitter.com/embed/Tweet.html?id=${id}`; + return ``; + } +}