diff options
Diffstat (limited to 'bridges/BlueskyBridge.php')
-rw-r--r-- | bridges/BlueskyBridge.php | 620 |
1 files changed, 620 insertions, 0 deletions
diff --git a/bridges/BlueskyBridge.php b/bridges/BlueskyBridge.php new file mode 100644 index 00000000..67bb5af9 --- /dev/null +++ b/bridges/BlueskyBridge.php @@ -0,0 +1,620 @@ +<?php + +class BlueskyBridge extends BridgeAbstract +{ + //Initial PR by [RSSBridge contributors](https://github.com/RSS-Bridge/rss-bridge/issues/4058). + //Modified from [©DIYgod and contributors at RSSHub](https://github.com/DIYgod/RSSHub/tree/master/lib/routes/bsky), MIT License'; + const NAME = 'Bluesky Bridge'; + const URI = 'https://bsky.app'; + const DESCRIPTION = 'Fetches posts from Bluesky'; + const MAINTAINER = 'mruac'; + const PARAMETERS = [ + [ + 'data_source' => [ + 'name' => 'Bluesky Data Source', + 'type' => 'list', + 'defaultValue' => 'Profile', + 'values' => [ + 'Profile' => 'getAuthorFeed', + ], + 'title' => 'Select the type of data source to fetch from Bluesky.' + ], + 'user_id' => [ + 'name' => 'User Handle or DID', + 'type' => 'text', + 'required' => true, + 'exampleValue' => 'did:plc:z72i7hdynmk6r22z27h6tvur', + 'title' => 'ATProto / Bsky.app handle or DID' + ], + 'feed_filter' => [ + 'name' => 'Feed type', + 'type' => 'list', + 'defaultValue' => 'posts_and_author_threads', + 'values' => [ + 'Posts feed' => 'posts_and_author_threads', + 'All posts and replies' => 'posts_with_replies', + 'Root posts only' => 'posts_no_replies', + 'Media only' => 'posts_with_media', + ] + ], + + 'include_reposts' => [ + 'name' => 'Include Reposts?', + 'type' => 'checkbox', + 'defaultValue' => 'checked' + ], + + 'include_reply_context' => [ + 'name' => 'Include Reply context?', + 'type' => 'checkbox' + ], + + 'verbose_title' => [ + 'name' => 'Use verbose feed item titles?', + 'type' => 'checkbox' + ] + ] + ]; + + private $profile; + + public function getName() + { + if (isset($this->profile)) { + if ($this->profile['handle'] === 'handle.invalid') { + return sprintf('Bluesky - %s', $this->profile['displayName']); + } else { + return sprintf('Bluesky - %s (@%s)', $this->profile['displayName'], $this->profile['handle']); + } + } + return parent::getName(); + } + + public function getURI() + { + if (isset($this->profile)) { + if ($this->profile['handle'] === 'handle.invalid') { + return self::URI . '/profile/' . $this->profile['did']; + } else { + return self::URI . '/profile/' . $this->profile['handle']; + } + } + return parent::getURI(); + } + + public function getIcon() + { + if (isset($this->profile)) { + return $this->profile['avatar']; + } + return parent::getIcon(); + } + + public function getDescription() + { + if (isset($this->profile)) { + return $this->profile['description']; + } + return parent::getDescription(); + } + + private function parseExternal($external, $did) + { + $description = ''; + $externalUri = $external['uri']; + $externalTitle = e($external['title']); + $externalDescription = e($external['description']); + $thumb = $external['thumb'] ?? null; + + if (preg_match('/http(|s):\/\/media\.tenor\.com/', $externalUri)) { + //tenor gif embed + $tenorInterstitial = str_replace('media.tenor.com', 'media1.tenor.com/m', $externalUri); + $description .= "<figure><a href=\"$tenorInterstitial\"><img src=\"$externalUri\"/></a><figcaption>$externalTitle</figcaption></figure>"; + } else { + //link embed preview + $host = parse_url($externalUri)['host']; + $thumbDesc = $thumb ? ('<img src="https://cdn.bsky.app/img/feed_thumbnail/plain/' . $did . '/' . $thumb['ref']['$link'] . '@jpeg"/>') : ''; + $externalDescription = strlen($externalDescription) > 0 ? "<figcaption>($host) $externalDescription</figcaption>" : ''; + $description .= '<br><blockquote><b><a href="' . $externalUri . '">' . $externalTitle . '</a></b>'; + $description .= '<figure>' . $thumbDesc . $externalDescription . '</figure></blockquote>'; + } + return $description; + } + + private function textToDescription($record) + { + if (isset($record['value'])) { + $record = $record['value']; + } + $text = $record['text']; + $text_copy = $text; + $text = nl2br(e($text)); + if (isset($record['facets'])) { + $facets = $record['facets']; + foreach ($facets as $facet) { + if ($facet['features'][0]['$type'] === 'app.bsky.richtext.facet#link') { + $substring = substr($text_copy, $facet['index']['byteStart'], $facet['index']['byteEnd'] - $facet['index']['byteStart']); + $text = str_replace($substring, '<a href="' . $facet['features'][0]['uri'] . '">' . $substring . '</a>', $text); + } + } + } + return $text; + } + + public function collectData() + { + $user_id = $this->getInput('user_id'); + $handle_match = preg_match('/(?:[a-zA-Z]*\.)+([a-zA-Z](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)/', $user_id, $handle_res); //gets the TLD in $handle_match[1] + $did_match = preg_match('/did:plc:[a-z2-7]{24}/', $user_id); //https://github.com/did-method-plc/did-method-plc#identifier-syntax + $exclude = ['alt', 'arpa', 'example', 'internal', 'invalid', 'local', 'localhost', 'onion']; //https://en.wikipedia.org/wiki/Top-level_domain#Reserved_domains + if ($handle_match == true && array_search($handle_res[1], $exclude) == false) { + //valid bsky handle + $did = $this->resolveHandle($user_id); + } elseif ($did_match == true) { + //valid DID + $did = $user_id; + } else { + returnClientError('Invalid ATproto handle or DID provided.'); + } + + $filter = $this->getInput('feed_filter') ?: 'posts_and_author_threads'; + $replyContext = $this->getInput('include_reply_context'); + + $this->profile = $this->getProfile($did); + $authorFeed = $this->getAuthorFeed($did, $filter); + + foreach ($authorFeed['feed'] as $post) { + $postRecord = $post['post']['record']; + + $item = []; + $item['uri'] = self::URI . '/profile/' . $this->fallbackAuthor($post['post']['author'], 'url') . '/post/' . explode('app.bsky.feed.post/', $post['post']['uri'])[1]; + $item['title'] = $this->getInput('verbose_title') ? $this->generateVerboseTitle($post) : strtok($postRecord['text'], "\n"); + $item['timestamp'] = strtotime($postRecord['createdAt']); + $item['author'] = $this->fallbackAuthor($post['post']['author'], 'display'); + + $postAuthorDID = $post['post']['author']['did']; + $postAuthorHandle = $post['post']['author']['handle'] !== 'handle.invalid' ? '<i>@' . $post['post']['author']['handle'] . '</i> ' : ''; + $postDisplayName = $post['post']['author']['displayName'] ?? ''; + $postDisplayName = e($postDisplayName); + $postUri = $item['uri']; + + if (Debug::isEnabled()) { + $url = explode('/', $post['post']['uri']); + error_log('https://bsky.app/profile/' . $url[2] . '/post/' . $url[4]); + } + + $description = ''; + $description .= '<p>'; + //post + $description .= $this->getPostDescription( + $postDisplayName, + $postAuthorHandle, + $postUri, + $postRecord, + 'post' + ); + + if (isset($postRecord['embed']['$type'])) { + //post link embed + if ($postRecord['embed']['$type'] === 'app.bsky.embed.external') { + $description .= $this->parseExternal($postRecord['embed']['external'], $postAuthorDID); + } elseif ( + $postRecord['embed']['$type'] === 'app.bsky.embed.recordWithMedia' && + $postRecord['embed']['media']['$type'] === 'app.bsky.embed.external' + ) { + $description .= $this->parseExternal($postRecord['embed']['media']['external'], $postAuthorDID); + } + + //post images + if ( + $postRecord['embed']['$type'] === 'app.bsky.embed.images' || + ( + $postRecord['embed']['$type'] === 'app.bsky.embed.recordWithMedia' && + $postRecord['embed']['media']['$type'] === 'app.bsky.embed.images' + ) + ) { + $images = $post['post']['embed']['images'] ?? $post['post']['embed']['media']['images']; + foreach ($images as $image) { + $description .= $this->getPostImageDescription($image); + } + } + + //post video + if ( + $postRecord['embed']['$type'] === 'app.bsky.embed.video' || + ( + $postRecord['embed']['$type'] === 'app.bsky.embed.recordWithMedia' && + $postRecord['embed']['media']['$type'] === 'app.bsky.embed.video' + ) + ) { + $description .= $this->getPostVideoDescription( + $postRecord['embed']['video'] ?? $postRecord['embed']['media']['video'], + $postAuthorDID + ); + } + } + $description .= '</p>'; + + //quote post + if ( + isset($postRecord['embed']) && + ( + $postRecord['embed']['$type'] === 'app.bsky.embed.record' || + $postRecord['embed']['$type'] === 'app.bsky.embed.recordWithMedia' + ) && + isset($post['post']['embed']['record']) + ) { + $description .= '<p>'; + $quotedRecord = $post['post']['embed']['record']['record'] ?? $post['post']['embed']['record']; + + if (isset($quotedRecord['notFound']) && $quotedRecord['notFound']) { //deleted post + $description .= 'Quoted post deleted.'; + } elseif (isset($quotedRecord['detached']) && $quotedRecord['detached']) { //detached quote + $uri_explode = explode('/', $quotedRecord['uri']); + $uri_reconstructed = self::URI . '/profile/' . $uri_explode[2] . '/post/' . $uri_explode[4]; + $description .= '<a href="' . $uri_reconstructed . '">Quoted post detached.</a>'; + } elseif (isset($quotedRecord['blocked']) && $quotedRecord['blocked']) { //blocked by quote author + $description .= 'Author of quoted post has blocked OP.'; + } elseif (($quotedRecord['$type'] ?? '') === 'app.bsky.feed.defs#generatorView') { + $description .= '</p>'; + $description .= $this->getGeneratorViewDescription($quotedRecord); + $description .= '<p>'; + } else { + $quotedAuthorDid = $quotedRecord['author']['did']; + $quotedDisplayName = $quotedRecord['author']['displayName'] ?? ''; + $quotedDisplayName = e($quotedDisplayName); + $quotedAuthorHandle = $quotedRecord['author']['handle'] !== 'handle.invalid' ? '<i>@' . $quotedRecord['author']['handle'] . '</i>' : ''; + + $parts = explode('/', $quotedRecord['uri']); + $quotedPostId = end($parts); + $quotedPostUri = self::URI . '/profile/' . $this->fallbackAuthor($quotedRecord['author'], 'url') . '/post/' . $quotedPostId; + + //quoted post - post + $description .= $this->getPostDescription( + $quotedDisplayName, + $quotedAuthorHandle, + $quotedPostUri, + $quotedRecord, + 'quote' + ); + + if (isset($quotedRecord['value']['embed']['$type'])) { + //quoted post - post link embed + if ($quotedRecord['value']['embed']['$type'] === 'app.bsky.embed.external') { + $description .= $this->parseExternal($quotedRecord['value']['embed']['external'], $quotedAuthorDid); + } + + //quoted post - post video + if ( + $quotedRecord['value']['embed']['$type'] === 'app.bsky.embed.video' || + ( + $quotedRecord['value']['embed']['$type'] === 'app.bsky.embed.recordWithMedia' && + $quotedRecord['value']['embed']['media']['$type'] === 'app.bsky.embed.video' + ) + ) { + $description .= $this->getPostVideoDescription( + $quotedRecord['value']['embed']['video'] ?? $quotedRecord['value']['embed']['media']['video'], + $quotedAuthorDid + ); + } + + //quoted post - post images + if ( + $quotedRecord['value']['embed']['$type'] === 'app.bsky.embed.images' || + ( + $quotedRecord['value']['embed']['$type'] === 'app.bsky.embed.recordWithMedia' && + $quotedRecord['value']['embed']['media']['$type'] === 'app.bsky.embed.images' + ) + ) { + foreach ($quotedRecord['embeds'] as $embed) { + if ( + $embed['$type'] === 'app.bsky.embed.images#view' || + ($embed['$type'] === 'app.bsky.embed.recordWithMedia#view' && $embed['media']['$type'] === 'app.bsky.embed.images#view') + ) { + $images = $embed['images'] ?? $embed['media']['images']; + foreach ($images as $image) { + $description .= $this->getPostImageDescription($image); + } + } + } + } + } + } + $description .= '</p>'; + } + + //reply + if ($replyContext && isset($post['reply']) && !isset($post['reply']['parent']['notFound'])) { + $replyPost = $post['reply']['parent']; + $replyPostRecord = $replyPost['record']; + $description .= '<hr/>'; + $description .= '<p>'; + + $replyPostAuthorDID = $replyPost['author']['did']; + $replyPostAuthorHandle = $replyPost['author']['handle'] !== 'handle.invalid' ? '<i>@' . $replyPost['author']['handle'] . '</i> ' : ''; + $replyPostDisplayName = $replyPost['author']['displayName'] ?? ''; + $replyPostDisplayName = e($replyPostDisplayName); + $replyPostUri = self::URI . '/profile/' . $this->fallbackAuthor($replyPost['author'], 'url') . '/post/' . explode('app.bsky.feed.post/', $replyPost['uri'])[1]; + + // reply post + $description .= $this->getPostDescription( + $replyPostDisplayName, + $replyPostAuthorHandle, + $replyPostUri, + $replyPostRecord, + 'reply' + ); + + if (isset($replyPostRecord['embed']['$type'])) { + //post link embed + if ($replyPostRecord['embed']['$type'] === 'app.bsky.embed.external') { + $description .= $this->parseExternal($replyPostRecord['embed']['external'], $replyPostAuthorDID); + } elseif ( + $replyPostRecord['embed']['$type'] === 'app.bsky.embed.recordWithMedia' && + $replyPostRecord['embed']['media']['$type'] === 'app.bsky.embed.external' + ) { + $description .= $this->parseExternal($replyPostRecord['embed']['media']['external'], $replyPostAuthorDID); + } + + //post images + if ( + $replyPostRecord['embed']['$type'] === 'app.bsky.embed.images' || + ( + $replyPostRecord['embed']['$type'] === 'app.bsky.embed.recordWithMedia' && + $replyPostRecord['embed']['media']['$type'] === 'app.bsky.embed.images' + ) + ) { + $images = $replyPost['embed']['images'] ?? $replyPost['embed']['media']['images']; + foreach ($images as $image) { + $description .= $this->getPostImageDescription($image); + } + } + + //post video + if ( + $replyPostRecord['embed']['$type'] === 'app.bsky.embed.video' || + ( + $replyPostRecord['embed']['$type'] === 'app.bsky.embed.recordWithMedia' && + $replyPostRecord['embed']['media']['$type'] === 'app.bsky.embed.video' + ) + ) { + $description .= $this->getPostVideoDescription( + $replyPostRecord['embed']['video'] ?? $replyPostRecord['embed']['media']['video'], + $replyPostAuthorDID + ); + } + } + $description .= '</p>'; + + //quote post + if ( + isset($replyPostRecord['embed']) && + ($replyPostRecord['embed']['$type'] === 'app.bsky.embed.record' || $replyPostRecord['embed']['$type'] === 'app.bsky.embed.recordWithMedia') && + isset($replyPost['embed']['record']) + ) { + $description .= '<p>'; + $replyQuotedRecord = $replyPost['embed']['record']['record'] ?? $replyPost['embed']['record']; + + if (isset($replyQuotedRecord['notFound']) && $replyQuotedRecord['notFound']) { //deleted post + $description .= 'Quoted post deleted.'; + } elseif (isset($replyQuotedRecord['detached']) && $replyQuotedRecord['detached']) { //detached quote + $uri_explode = explode('/', $replyQuotedRecord['uri']); + $uri_reconstructed = self::URI . '/profile/' . $uri_explode[2] . '/post/' . $uri_explode[4]; + $description .= '<a href="' . $uri_reconstructed . '">Quoted post detached.</a>'; + } elseif (isset($replyQuotedRecord['blocked']) && $replyQuotedRecord['blocked']) { //blocked by quote author + $description .= 'Author of quoted post has blocked OP.'; + } elseif (($replyQuotedRecord['$type'] ?? '') === 'app.bsky.feed.defs#generatorView') { + $description .= '</p>'; + $description .= $this->getGeneratorViewDescription($replyQuotedRecord); + $description .= '<p>'; + } else { + $quotedAuthorDid = $replyQuotedRecord['author']['did']; + $quotedDisplayName = $replyQuotedRecord['author']['displayName'] ?? ''; + $quotedDisplayName = e($quotedDisplayName); + $quotedAuthorHandle = $replyQuotedRecord['author']['handle'] !== 'handle.invalid' ? '<i>@' . $replyQuotedRecord['author']['handle'] . '</i>' : ''; + + $parts = explode('/', $replyQuotedRecord['uri']); + $quotedPostId = end($parts); + $quotedPostUri = self::URI . '/profile/' . $this->fallbackAuthor($replyQuotedRecord['author'], 'url') . '/post/' . $quotedPostId; + + //quoted post - post + $description .= $this->getPostDescription( + $quotedDisplayName, + $quotedAuthorHandle, + $quotedPostUri, + $replyQuotedRecord, + 'quote' + ); + + if (isset($replyQuotedRecord['value']['embed']['$type'])) { + //quoted post - post link embed + if ($replyQuotedRecord['value']['embed']['$type'] === 'app.bsky.embed.external') { + $description .= $this->parseExternal($replyQuotedRecord['value']['embed']['external'], $quotedAuthorDid); + } + + //quoted post - post video + if ( + $replyQuotedRecord['value']['embed']['$type'] === 'app.bsky.embed.video' || + ( + $replyQuotedRecord['value']['embed']['$type'] === 'app.bsky.embed.recordWithMedia' && + $replyQuotedRecord['value']['embed']['media']['$type'] === 'app.bsky.embed.video' + ) + ) { + $description .= $this->getPostVideoDescription( + $replyQuotedRecord['value']['embed']['video'] ?? $replyQuotedRecord['value']['embed']['media']['video'], + $quotedAuthorDid + ); + } + + //quoted post - post images + if ( + $replyQuotedRecord['value']['embed']['$type'] === 'app.bsky.embed.images' || + ( + $replyQuotedRecord['value']['embed']['$type'] === 'app.bsky.embed.recordWithMedia' && + $replyQuotedRecord['value']['embed']['media']['$type'] === 'app.bsky.embed.images' + ) + ) { + foreach ($replyQuotedRecord['embeds'] as $embed) { + if ( + $embed['$type'] === 'app.bsky.embed.images#view' || + ($embed['$type'] === 'app.bsky.embed.recordWithMedia#view' && $embed['media']['$type'] === 'app.bsky.embed.images#view') + ) { + $images = $embed['images'] ?? $embed['media']['images']; + foreach ($images as $image) { + $description .= $this->getPostImageDescription($image); + } + } + } + } + } + } + $description .= '</p>'; + } + } + + $item['content'] = $description; + $this->items[] = $item; + } + } + + private function getPostVideoDescription(array $video, $authorDID) + { + //https://video.bsky.app/watch/$did/$cid/thumbnail.jpg + $videoCID = $video['ref']['$link']; + $videoMime = $video['mimeType']; + $thumbnail = "poster=\"https://video.bsky.app/watch/$authorDID/$videoCID/thumbnail.jpg\"" ?? ''; + $videoURL = "https://bsky.social/xrpc/com.atproto.sync.getBlob?did=$authorDID&cid=$videoCID"; + return "<figure><video loop $thumbnail controls src=\"$videoURL\" type=\"$videoMime\"/></figure>"; + } + + private function getPostImageDescription(array $image) + { + $thumbnailUrl = $image['thumb']; + $fullsizeUrl = $image['fullsize']; + $alt = strlen($image['alt']) > 0 ? '<figcaption>' . e($image['alt']) . '</figcaption>' : ''; + return "<figure><a href=\"$fullsizeUrl\"><img src=\"$thumbnailUrl\"></a>$alt</figure>"; + } + + private function getPostDescription( + string $postDisplayName, + string $postAuthorHandle, + string $postUri, + array $postRecord, + string $type + ) { + $description = ''; + if ($type === 'quote') { + // Quoted post/reply from bbb @bbb.com: + $postType = isset($postRecord['reply']) ? 'reply' : 'post'; + $description .= "<a href=\"$postUri\">Quoted $postType</a> from <b>$postDisplayName</b> $postAuthorHandle:<br>"; + } elseif ($type === 'reply') { + // Replying to aaa @aaa.com's post/reply: + $postType = isset($postRecord['reply']) ? 'reply' : 'post'; + $description .= "Replying to <b>$postDisplayName</b> $postAuthorHandle's <a href=\"$postUri\">$postType</a>:<br>"; + } else { + // aaa @aaa.com posted: + $description .= "<b>$postDisplayName</b> $postAuthorHandle <a href=\"$postUri\">posted</a>:<br>"; + } + $description .= $this->textToDescription($postRecord); + return $description; + } + + //used if handle verification fails, fallsback to displayName or DID depending on context. + private function fallbackAuthor($author, $reason) + { + if ($author['handle'] === 'handle.invalid') { + switch ($reason) { + case 'url': + return $author['did']; + case 'display': + $displayName = $author['displayName'] ?? ''; + return e($displayName); + } + } + return $author['handle']; + } + + private function generateVerboseTitle($post) + { + //use "Post by A, replying to B, quoting C" instead of post contents + $title = ''; + if (isset($post['reason']) && str_contains($post['reason']['$type'], 'reasonRepost')) { + $title .= 'Repost by ' . $this->fallbackAuthor($post['reason']['by'], 'display') . ', post by ' . $this->fallbackAuthor($post['post']['author'], 'display'); + } else { + $title .= 'Post by ' . $this->fallbackAuthor($post['post']['author'], 'display'); + } + + if (isset($post['reply'])) { + if (isset($post['reply']['parent']['blocked'])) { + $replyAuthor = 'blocked user'; + } elseif (isset($post['reply']['parent']['notFound'])) { + $replyAuthor = 'deleted post'; + } else { + $replyAuthor = $this->fallbackAuthor($post['reply']['parent']['author'], 'display'); + } + $title .= ', replying to ' . $replyAuthor; + } + if (isset($post['post']['embed']) && isset($post['post']['embed']['record'])) { + if (isset($post['post']['embed']['record']['blocked'])) { + $quotedAuthor = 'blocked user'; + } elseif (isset($post['post']['embed']['record']['notFound'])) { + $quotedAuthor = 'deleted post'; + } elseif (isset($post['post']['embed']['record']['detached'])) { + $quotedAuthor = 'detached post'; + } else { + $quotedAuthor = $this->fallbackAuthor($post['post']['embed']['record']['record']['author'] ?? $post['post']['embed']['record']['author'], 'display'); + } + $title .= ', quoting ' . $quotedAuthor; + } + return $title; + } + + private function resolveHandle($handle) + { + $uri = 'https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=' . urlencode($handle); + $response = json_decode(getContents($uri), true); + return $response['did']; + } + + private function getProfile($did) + { + $uri = 'https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=' . urlencode($did); + $response = json_decode(getContents($uri), true); + return $response; + } + + private function getAuthorFeed($did, $filter) + { + $uri = 'https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=' . urlencode($did) . '&filter=' . urlencode($filter) . '&limit=30'; + if (Debug::isEnabled()) { + error_log($uri); + } + $response = json_decode(getContents($uri), true); + return $response; + } + + private function getGeneratorViewDescription(array $record): string + { + $avatar = e($record['avatar']); + $displayName = e($record['displayName']); + $displayHandle = e($record['creator']['handle']); + $likeCount = e($record['likeCount']); + preg_match('/\/([^\/]+)$/', $record['uri'], $matches); + $uri = e('https://bsky.app/profile/' . $record['creator']['did'] . '/feed/' . $matches[1]); + + return <<<END +<a href="{$uri}" style="color: inherit;"> + <div style="border: 1px solid #333; padding: 10px;"> + <div style="display: flex; margin-bottom: 10px;"> + <img src="{$avatar}" height="50" width="50" style="margin-right: 10px;"> + <div style="display: flex; flex-direction: column; justify-content: center;"> + <h3>{$displayName}</h3> + <span>Feed by @{$displayHandle}</span> + </div> + </div> + <span>Liked by {$likeCount} users</span> + </div> +</a> +END; + } +} |