diff --git a/assets/css/base.css b/assets/css/base.css index bceffe7..2067b73 100644 --- a/assets/css/base.css +++ b/assets/css/base.css @@ -243,7 +243,7 @@ li.control-bar-action:hover { transform: translateX(-50%); overflow: hidden; width: fit-content; - z-index: 1; + z-index: 2; } .control-bar-dropdown-menu li { diff --git a/assets/css/blog.css b/assets/css/blog.css index a1ef651..535557a 100644 --- a/assets/css/blog.css +++ b/assets/css/blog.css @@ -18,6 +18,9 @@ } #banner { + width: 100%; + height: 100%; + object-fit: cover; border-radius: 10px 10px 0px 0px; } diff --git a/assets/css/post-layout.css b/assets/css/post-layout.css index 9dba053..6022d65 100644 --- a/assets/css/post-layout.css +++ b/assets/css/post-layout.css @@ -84,4 +84,64 @@ a.inline-link, a.inline-mention { color: #6b7280; text-transform: uppercase; font-size: 12px; -} \ No newline at end of file +} + +a.toggle-poll-results { + color: unset; + text-decoration: underline; +} + +.poll-block { + gap: 15px; + border: unset; + + padding: 0 10px; + margin: 10px 0; +} + +.poll-block > header > h3 { + font-weight: normal; + margin: 0; + font-size: 24px; +} + +.poll-choice { + border: 0; + border-radius: 10px; + background: #edeeee; + padding: 8px 18px; + overflow: hidden; +} + +.poll-choice > .answer { + font-weight: 550; +} + +.vote-proportion { + background: #dcdcdc; + border-radius: 10px 0px 0px 10px; +} + +.poll-winner >.vote-proportion { + background: #BABABA; +} + +.poll-block footer { + font-size: 15px; + margin-top: 0; + color: #6b7280; +} + +.toggle-poll-results { + display: block; + margin-top: 10px; + margin-bottom: 20px; +} + +.toggle-poll-results:hover { + font-weight: 700; +} + +.separator { + font-size: 12px; +} diff --git a/assets/js/post.js b/assets/js/post.js new file mode 100644 index 0000000..a8f4649 --- /dev/null +++ b/assets/js/post.js @@ -0,0 +1,110 @@ +"use strict"; + +function requestPollResults(poll_element, pollId) { + return new Promise(function (resolve, reject) { + let post = poll_element.closest(".post"); + const blogName = post.getElementsByClassName("blog-name")[0].innerHTML; + const postId = post.dataset.postId; + + const pollResultsFetch = fetch(`/api/v1/poll/${blogName}/${postId}/${pollId}/results`); + + pollResultsFetch.then((results) => { + return results.json(); + }).then((parsed_results) => { + return resolve(parsed_results); + }); + }) +} + +function fill_poll_results(poll_element, results) { + const sorted_poll_results = Object.entries(results.response.results).sort((a,b) => (a[1]-b[1])).reverse(); + + // First we must find the total number of votes and the winner(s) of the poll + let total_votes = 0; + + // Answer ID to winner status and amount of votes + const processed_poll_results = {}; + const winner_vote_count = sorted_poll_results[0][1]; + + for (let [answer_id, votes] of sorted_poll_results) { + processed_poll_results[answer_id] = {"is_winner": (winner_vote_count == votes), "votes": votes}; + total_votes += votes + } + + const answerIdChoiceElementArray = []; + const pollBody = poll_element.getElementsByClassName("poll-body")[0]; + + for (let choiceElement of pollBody.children) { + answerIdChoiceElementArray.push([choiceElement.dataset.answerId, choiceElement]); + } + + for (let [answer_id, choiceElement] of answerIdChoiceElementArray) { + const answer_results = processed_poll_results[answer_id] || {"is_winner": false, "votes": 0}; + const is_winner = answer_results.is_winner; + const answer_votes = answer_results.votes; + + let numericalVoteProportion + + if (answer_votes == 0 || total_votes == 0) { + numericalVoteProportion = 0; + } else { + numericalVoteProportion = answer_votes/total_votes; + } + + const voteProportionElement = document.createElement("span"); + voteProportionElement.classList.add("vote-proportion"); + voteProportionElement["style"] = `width: ${((numericalVoteProportion) * 100).toFixed(3)}%;`; + + const voteCountElement = document.createElement("span"); + voteCountElement.classList.add("vote-count"); + + // A greater rounding precision is needed here + const comparison = Math.round((numericalVoteProportion) * 10000)/10000 + if ((comparison > 0.001) || comparison == 0) { + voteCountElement.innerHTML = new Intl.NumberFormat("en-US", {style: "percent", maximumSignificantDigits: 3}).format(Math.round((numericalVoteProportion) * 1000)/1000); + } else { + voteCountElement.innerHTML = "< 0.1%"; + } + + if (is_winner) { + choiceElement.classList.add("poll-winner"); + } + + choiceElement.appendChild(voteProportionElement); + choiceElement.appendChild(voteCountElement); + } + + const pollMetadataElement = poll_element.getElementsByClassName("poll-metadata")[0] + + const totalVotesElement = document.createElement("span") + totalVotesElement.innerHTML = `${total_votes} votes` + + const separatorElement = document.createElement("span") + separatorElement.innerHTML = '•' + separatorElement.classList.add("separator") + + const pollTimestamp = pollMetadataElement.children[0] + + const newMetadataContents = document.createDocumentFragment() + newMetadataContents.append(totalVotesElement, separatorElement, pollTimestamp.cloneNode(true)) + + pollTimestamp.replaceWith(newMetadataContents) + + poll_element.classList.add("populated") +} + +const pollBlocks = document.getElementsByClassName("poll-block"); + + +function populate_polls() { + for (let poll of pollBlocks) { + if (poll.classList.contains("populated")) { + continue; + } + + requestPollResults(poll, poll.dataset.pollId).then((answers) => {fill_poll_results(poll, answers)}) + } +}; + +// TODO lazy load polls +populate_polls(); diff --git a/requirements.txt b/requirements.txt index 75b28e7..9569c82 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,10 +10,11 @@ frozenlist==1.4.1 html5tagger==1.3.0 httptools==0.6.1 idna==3.6 +intervaltree==3.1.0 Jinja2==3.1.2 MarkupSafe==2.1.4 multidict==6.0.5 -npf-renderer==0.11.1 +npf-renderer==0.12.0 orjson==3.8.0 pycares==4.4.0 pycparser==2.21 @@ -21,6 +22,7 @@ PyYAML==6.0.1 sanic==23.3.0 sanic-ext==23.6.0 sanic-routing==23.6.0 +sortedcontainers==2.4.0 tracerite==1.1.1 ujson==5.9.0 uvloop==0.19.0 diff --git a/src/helpers/ext_npf_renderer.py b/src/helpers/ext_npf_renderer.py new file mode 100644 index 0000000..34d0f29 --- /dev/null +++ b/src/helpers/ext_npf_renderer.py @@ -0,0 +1,172 @@ +"""Extensions to npf-renderer to allow asynchronous code and some other custom styling""" + +import dominate +import npf_renderer + +from .helpers import url_handler + + +class NPFParser(npf_renderer.parse.Parser): + def __init__(self, content, poll_callback=None): + super().__init__(content, poll_callback) + + async def _parse_poll_block(self): + poll_id = self.current.get("clientId") or self.current.get("client_id") + if poll_id is None: + raise ValueError("Invalid poll ID") + + question = self.current["question"] + + answers = {} + for raw_ans in self.current["answers"]: + answer_id = raw_ans.get("clientId") or raw_ans.get("client_id") + answer_text = raw_ans.get("answerText") or raw_ans.get("answer_text") + + if answer_id is None or answer_text is None: + raise ValueError("Invalid poll answer") + + answers[answer_id] = answer_text + + votes = None + total_votes = None + + if self.poll_result_callback: + callback_response = await self.poll_result_callback(poll_id) + + #{answer_id: vote_count} + raw_results = callback_response["results"].items() + processed_results = sorted(raw_results, key=lambda item: -item[1]) + + votes_dict = {} + total_votes = 0 + + for index, results in enumerate(processed_results): + vote_count = results[1] + total_votes += vote_count + + if index == 0: + votes_dict[results[0]] = npf_renderer.objects.poll_block.PollResult(is_winner=True, vote_count=vote_count) + else: + votes_dict[results[0]] = npf_renderer.objects.poll_block.PollResult(is_winner=False, vote_count=vote_count) + + votes = npf_renderer.objects.poll_block.PollResults( + timestamp=callback_response["timestamp"], + results=votes_dict + ) + + creation_timestamp = self.current["timestamp"] + expires_after = self.current["settings"]["expireAfter"] + + return npf_renderer.objects.poll_block.PollBlock( + poll_id=poll_id, + question=question, + answers=answers, + + creation_timestamp=int(creation_timestamp), + expires_after=int(expires_after), + + votes=votes, + total_votes=total_votes, + ) + + async def __parse_block(self): + """Parses a content block and appends the result to self.parsed_result + + Works by routing specific content types to corresponding parse methods + """ + + match self.current["type"]: + case "text": + block = self._parse_text() + case "image": + block = self._parse_image_block() + case "link": + block = self._parse_link_block() + case "audio": + block = self._parse_audio_block() + case "video": + block = self._parse_video_block() + case "poll": + block = await self._parse_poll_block() + case _: + block = unsupported.Unsupported(self.current["type"]) + + self.parsed_result.append(block) + + async def parse(self): + """Begins the parsing chain and returns the final list of parsed objects""" + while self.next(): + await self.__parse_block() + + return self.parsed_result + + +class NPFFormatter(npf_renderer.format.Formatter): + def __init__(self, content, layout=None, blog_name=None, post_id=None, *, url_handler=None, forbid_external_iframes=False): + super().__init__(content, layout, url_handler=url_handler, forbid_external_iframes=forbid_external_iframes) + + # We store the blog and post ID as to be able to render a link to + # fetch poll results for JS disabled users + self.blog_name = blog_name + self.post_id = post_id + + def _format_poll(self, block): + poll_html = super()._format_poll(block) + poll_html["data-poll-id"] = block.poll_id + + poll_body = poll_html[1] + for index, answer_id in enumerate(block.answers.keys()): + poll_body[index]["data-answer-id"] = answer_id + + if (self.blog_name and self.post_id) and not block.votes: + poll_footer = poll_html[2] + no_script_fallback = dominate.tags.noscript( + dominate.tags.a( + "See Results", + href=f"/{self.blog_name}/{self.post_id}?fetch_polls=true", + cls="toggle-poll-results" + ) + ) + + poll_footer.children.insert(0, no_script_fallback) + + return poll_html + + +async def format_npf(contents, layouts=None, blog_name=None, post_id=None,*, poll_callback=None): + """Wrapper around npf_renderer.format_npf for extra functionalities + + - Replaces internal Parser and Formatter with the modified variants above + - Accepts extra arguments to add additional details to formatted results + - Automatically sets Priviblur-specific rendering arguments + + Arguments (new): + blog_name: + Name of the blog the post comes from. This is used to render links to the parent post + post_id: + Unique ID of the post. This is used to render links to the parent post + """ + try: + contents = await NPFParser(contents, poll_callback=poll_callback).parse() + if layouts: + layouts = npf_renderer.parse.LayoutParser(layouts).parse() + + contains_render_errors = False + + formatted = NPFFormatter( + contents, layouts, + blog_name=blog_name, + post_id=post_id, + url_handler=url_handler, + forbid_external_iframes=True, + ).format() + + except npf_renderer.exceptions.RenderErrorDisclaimerError as e: + contains_render_errors = True + formatted = e.rendered_result + assert formatted is not None + except Exception as e: + formatted = dominate.tags.div(cls="post-body has-error") + contains_render_errors = True + + return contains_render_errors, formatted.render(pretty=False) diff --git a/src/helpers/helpers.py b/src/helpers/helpers.py index 2ef2464..6110118 100644 --- a/src/helpers/helpers.py +++ b/src/helpers/helpers.py @@ -34,6 +34,10 @@ def url_handler(raw_url): return f"/tblr/media/44{url.path}" elif hostname.endswith("static.tumblr.com"): return f"/tblr/static{url.path}" + elif hostname.startswith("va.media"): + return f"/tblr/media/va{url.path}" + elif hostname.startswith("a."): + return f"/tblr/a{url.path}" else: # Check for subdomain blog sub_domains = hostname.split(".") @@ -95,4 +99,11 @@ def translate(language, id, number=None, substitution=None): translated = translated.format(substitution) return translated - \ No newline at end of file + + +async def create_poll_callback(tumblr_api, blog, post_id): + async def poll_callable(poll_id): + initial_results = await tumblr_api.poll_results(blog, post_id, poll_id) + return initial_results["response"] + + return poll_callable \ No newline at end of file diff --git a/src/priviblur_extractor/api/api.py b/src/priviblur_extractor/api/api.py index dd574b3..cc42fa0 100644 --- a/src/priviblur_extractor/api/api.py +++ b/src/priviblur_extractor/api/api.py @@ -171,7 +171,7 @@ async def explore_today(self, *, continuation: Optional[str] = None, fields: str url_parameters["cursor"] = continuation return await self._get_json("explore/home/today", url_parameters) - + async def explore_post(self, post_type: rconf.ExplorePostTypeFilters, *, continuation: Optional[str] = None, reblog_info: bool = True, fields: str = rconf.EXPLORE_BLOG_INFO_FIELDS,): @@ -312,4 +312,17 @@ async def blog_post(self, blog_name, post_id): return await self._get_json( f"blog/{urllib.parse.quote(blog_name, safe='')}/posts/{post_id}/permalink", url_params={"fields[blogs]": rconf.POST_BLOG_INFO_FIELDS, "reblog_info": True} - ) \ No newline at end of file + ) + + async def poll_results(self, blog_name, post_id, poll_id): + """Requests the /polls////results endpoint + + Parameters: + blog_name: the blog the post is from + post_id: the id of the post + poll_id: the id of the poll + """ + + return await self._get_json( + f"polls/{urllib.parse.quote(blog_name, safe='')}/{post_id}/{poll_id}/results", + ) diff --git a/src/routes/__init__.py b/src/routes/__init__.py index cef4d0c..18eb02e 100644 --- a/src/routes/__init__.py +++ b/src/routes/__init__.py @@ -1,4 +1,4 @@ -from . import explore, media, search, tagged, blogs, assets, priviblur, miscellaneous +from . import explore, media, search, tagged, blogs, assets, priviblur, miscellaneous, api BLUEPRINTS = ( explore.explore, @@ -8,5 +8,6 @@ blogs.blogs, assets.assets, priviblur.priviblur, - miscellaneous.miscellaneous + miscellaneous.miscellaneous, + api.api ) diff --git a/src/routes/api/__init__.py b/src/routes/api/__init__.py new file mode 100644 index 0000000..1344120 --- /dev/null +++ b/src/routes/api/__init__.py @@ -0,0 +1,7 @@ +from sanic import Blueprint +from .v1 import v1 + +api = Blueprint.group( + v1, + url_prefix="/api" +) \ No newline at end of file diff --git a/src/routes/api/v1/__init__.py b/src/routes/api/v1/__init__.py new file mode 100644 index 0000000..7ec8fea --- /dev/null +++ b/src/routes/api/v1/__init__.py @@ -0,0 +1,8 @@ +from sanic import Blueprint + +from .misc import misc + +v1 = Blueprint.group( + misc, + url_prefix="/v1" +) \ No newline at end of file diff --git a/src/routes/api/v1/misc.py b/src/routes/api/v1/misc.py new file mode 100644 index 0000000..faf346e --- /dev/null +++ b/src/routes/api/v1/misc.py @@ -0,0 +1,13 @@ +import urllib.parse + +import sanic + +misc = sanic.Blueprint("api_misc", url_prefix="/") + +@misc.get("/poll////results") +async def poll_results(request, blog : str, post_id : int, poll_id : int): + blog = urllib.parse.unquote(blog) + poll_id = urllib.parse.unquote(poll_id) + + initial_results = await request.app.ctx.TumblrAPI.poll_results(blog, post_id, poll_id) + return sanic.response.json(initial_results, headers={"Cache-Control": "max-age=600, immutable"}) \ No newline at end of file diff --git a/src/routes/blogs.py b/src/routes/blogs.py index edab9c4..5f4b428 100644 --- a/src/routes/blogs.py +++ b/src/routes/blogs.py @@ -9,13 +9,14 @@ blogs = sanic.Blueprint("blogs", url_prefix="/") -async def render_blog_post(app, blog, post): +async def render_blog_post(app, blog, post, request_poll_data = False): return await sanic_ext.render( "blog_post.jinja", context={ "app": app, "blog": blog, "element": post, + "request_poll_data" : request_poll_data } ) @@ -75,13 +76,18 @@ async def _blog_post_no_slug(request: sanic.Request, blog: str, post_id: str): post = timeline.elements[0] if post.slug: - return sanic.redirect(request.app.url_for("blogs._blog_post", blog=blog, post_id=post_id, slug=post.slug)) + return sanic.redirect(request.app.url_for("blogs._blog_post", blog=blog, post_id=post_id, slug=post.slug, **request.args)) else: # Fetch blog info and some posts from before this post initial_blog_results = await request.app.ctx.TumblrAPI.blog_posts(blog, before_id=post.id) blog_info = priviblur_extractor.parse_container(initial_blog_results) - return await render_blog_post(request.app, blog_info, post) + if request.args.get("fetch_polls") in {1, "true"}: + fetch_poll_results = True + else: + fetch_poll_results = False + + return await render_blog_post(request.app, blog_info, post, fetch_poll_results) @blogs.get("//") @@ -97,15 +103,20 @@ async def _blog_post(request: sanic.Request, blog: str, post_id: str, slug: str) if post.slug != slug: # Unless of course the slug is empty. In that case we'll remove the slug. if post.slug: - return sanic.redirect(request.app.url_for("blogs._blog_post", blog=blog, post_id=post_id, slug=post.slug)) + return sanic.redirect(request.app.url_for("blogs._blog_post", blog=blog, post_id=post_id, slug=post.slug, **request.args)) else: - return sanic.redirect(request.app.url_for("blogs._blog_post_no_slug", blog=blog, post_id=post_id)) + return sanic.redirect(request.app.url_for("blogs._blog_post_no_slug", blog=blog, post_id=post_id, **request.args)) else: # Fetch blog info and some posts from before this post initial_blog_results = await request.app.ctx.TumblrAPI.blog_posts(blog, before_id=post.id) blog_info = priviblur_extractor.parse_container(initial_blog_results) - return await render_blog_post(request.app, blog_info, post) + if request.args.get("fetch_polls") in ("1", "true"): + fetch_poll_results = True + else: + fetch_poll_results = False + + return await render_blog_post(request.app, blog_info, post, fetch_poll_results) # Redirects for /post/... diff --git a/src/routes/explore.py b/src/routes/explore.py index 93ea3a7..a7acb49 100644 --- a/src/routes/explore.py +++ b/src/routes/explore.py @@ -4,8 +4,6 @@ import sanic import sanic_ext -import npf_renderer - from .. import priviblur_extractor explore = sanic.Blueprint("explore", url_prefix="/explore") diff --git a/src/routes/media.py b/src/routes/media.py index 03a2d55..5992c80 100644 --- a/src/routes/media.py +++ b/src/routes/media.py @@ -1,12 +1,13 @@ import sanic +import aiohttp from ..helpers import exceptions media = sanic.Blueprint("TumblrMedia", url_prefix="/tblr") -async def get_media(request, client, path_to_request): - async with client.get(f"/{path_to_request}") as tumblr_response: +async def get_media(request, client : aiohttp.ClientSession, path_to_request, additional_headers = None): + async with client.get(f"/{path_to_request}", headers=additional_headers) as tumblr_response: # Sanitize the headers given by Tumblr priviblur_response_headers = {} for header_key, header_value in tumblr_response.headers.items(): @@ -28,6 +29,8 @@ async def get_media(request, client, path_to_request): async for chunk in tumblr_response.content.iter_any(): await priviblur_response.send(chunk) + await priviblur_response.eof() + @media.get(r"/media/64/") async def _64_media(request: sanic.Request, path: str): @@ -47,6 +50,25 @@ async def _44_media(request: sanic.Request, path: str): return await get_media(request, request.app.ctx.Media44Client, path) +@media.get(r"/media/va/") +async def _va_media(request: sanic.Request, path: str): + """Proxies the requested media from va.media.tumblr.com""" + additional_headers={ + "accept": "video/webm,video/ogg,video/*;q=0.9," \ + "application/ogg;q=0.7,audio/*;q=0.6,*/*;q=0.5" + } + return await get_media(request, request.app.ctx.MediaVaClient, path, additional_headers=additional_headers) + + +@media.get(r"/a/") +async def _a_media(request: sanic.Request, path: str): + """Proxies the requested media from va.media.tumblr.com""" + additional_headers={ + "accept": "audio/webm,audio/ogg,audio/wav,audio/*;q=0.9,application/ogg;q=0.7,video/*;q=0.6,*/*;q=0.5" + } + return await get_media(request, request.app.ctx.AudioClient, path, additional_headers=additional_headers) + + @media.get(r"/assets/") async def _tb_assets(request: sanic.Request, path: str): """Proxies the requested media from assets.tumblr.com""" diff --git a/src/routes/search.py b/src/routes/search.py index 1c07f55..731b06f 100644 --- a/src/routes/search.py +++ b/src/routes/search.py @@ -4,7 +4,6 @@ import sanic import sanic_ext -import npf_renderer from .. import priviblur_extractor search = sanic.Blueprint("search", url_prefix="/search") diff --git a/src/routes/tagged.py b/src/routes/tagged.py index 3d03525..f9a05c9 100644 --- a/src/routes/tagged.py +++ b/src/routes/tagged.py @@ -4,8 +4,6 @@ import sanic import sanic_ext -import npf_renderer - from .. import priviblur_extractor tagged = sanic.Blueprint("tagged", url_prefix="/tagged") diff --git a/src/server.py b/src/server.py index 6ba8922..0eb5b9b 100644 --- a/src/server.py +++ b/src/server.py @@ -14,12 +14,12 @@ from sanic import Sanic import babel.numbers import babel.dates -from npf_renderer import VERSION as NPF_RENDERER_VERSION, format_npf +from npf_renderer import VERSION as NPF_RENDERER_VERSION from . import routes, priviblur_extractor from . import priviblur_extractor from .config import load_config -from .helpers import setup_logging, helpers, error_handlers, exceptions +from .helpers import setup_logging, helpers, error_handlers, exceptions, ext_npf_renderer from .version import VERSION, CURRENT_COMMIT @@ -105,6 +105,14 @@ def create_image_client(url, timeout): "https://44.media.tumblr.com", priviblur_backend.image_response_timeout ) + app.ctx.MediaVaClient = create_image_client( + "https://va.media.tumblr.com", priviblur_backend.image_response_timeout + ) + + app.ctx.AudioClient = create_image_client( + "https://a.tumblr.com", priviblur_backend.image_response_timeout + ) + app.ctx.TumblrAssetClient = create_image_client( "https://assets.tumblr.com", priviblur_backend.image_response_timeout ) @@ -134,11 +142,8 @@ def create_image_client(url, timeout): app.ext.environment.globals["translate"] = helpers.translate app.ext.environment.globals["url_handler"] = helpers.url_handler - app.ext.environment.globals["format_npf"] = functools.partial( - format_npf, - url_handler=helpers.url_handler, - skip_cropped_images=True - ) + app.ext.environment.globals["format_npf"] = ext_npf_renderer.format_npf + app.ext.environment.globals["create_poll_callback"] = helpers.create_poll_callback @app.listener("main_process_start") diff --git a/src/templates/base.jinja b/src/templates/base.jinja index bade2d5..aceb6a5 100644 --- a/src/templates/base.jinja +++ b/src/templates/base.jinja @@ -10,6 +10,7 @@ + {%- block head -%} {%- endblock -%} diff --git a/src/templates/components/post.jinja b/src/templates/components/post.jinja index 3ff97e5..9fcb21f 100644 --- a/src/templates/components/post.jinja +++ b/src/templates/components/post.jinja @@ -1,7 +1,24 @@ {% from 'macros/post_header.jinja' import create_post_header %} -
+
{%- if element.content -%} - {% set contains_errors, content_tag = format_npf(element.content, element.layout) -%} + {%- if request_poll_data -%} + {%- set contains_errors, content_tag = format_npf( + element.content, + element.layout, + poll_callback=create_poll_callback( + request.app.ctx.TumblrAPI, element.blog.name, element.id + ), + ) + -%} + {%- else -%} + {%- set contains_errors, content_tag = format_npf( + element.content, + element.layout, + element.blog.name, + element.id, + ) + -%} + {% endif %} {%- endif -%} {{ create_post_header(element) }} @@ -14,7 +31,25 @@ {%- for trail_element in element.trail -%}
{{create_post_header(trail_element)}} - {% set trail_contains_errors, trail_content_tag = format_npf(trail_element.content, trail_element.layout) -%} + {%- if request_poll_data -%} + {%- set trail_contains_errors, trail_content_tag = format_npf( + trail_element.content, + trail_element.layout, + poll_callback=create_poll_callback( + request.app.ctx.TumblrAPI, element.blog.name, element.id + ), + ) + -%} + + {%- else -%} + {%- set trail_contains_errors, trail_content_tag = format_npf( + trail_element.content, + trail_element.layout, + element.blog.name, + element.id, + ) + -%} + {% endif %} {{- trail_content_tag -}}
{%- endfor -%}