Files
indiekit-endpoint-activitypub/views/partials/ap-item-card.njk
Ricardo 1dc42ad5e5 feat: outbound Delete, visibility addressing, CW/sensitive, polls, Flag reports (v2.10.0)
- Outbound Delete: broadcastDelete() + POST /admin/federation/delete route
- Visibility: unlisted + followers-only addressing via defaultVisibility config
- Content Warning: outbound sensitive flag + summary as CW text
- Polls: inbound Question/poll parsing with progress bar rendering
- Flag: inbound report handler with ap_reports collection + Reports tab
- Includes DM support files from v2.9.x (messages controller, storage, templates)
- Includes coverage audit and high-impact gaps implementation plan

Confab-Link: http://localhost:8080/sessions/cc343b15-8d10-43cd-a48f-ca912eb79b83
2026-03-14 08:51:44 +01:00

285 lines
14 KiB
Plaintext

{# Timeline item card partial - reusable across timeline and profile views #}
{# Skip empty cards (e.g. Lemmy/PieFed activity IDs with no actual content) #}
{% set hasCardContent = item.content and (item.content.html or item.content.text) %}
{% set hasCardTitle = item.name %}
{% set hasCardMedia = (item.photo and item.photo.length > 0) or (item.video and item.video.length > 0) or (item.audio and item.audio.length > 0) %}
{% if hasCardContent or hasCardTitle or hasCardMedia %}
<article class="ap-card{% if item.type %} ap-card--{{ item.type }}{% endif %}{% if item.inReplyTo %} ap-card--reply{% endif %}{% if item._moderated %} ap-card--moderated{% endif %}{% if item.read %} ap-card--read{% endif %}" data-uid="{{ item.uid }}">
{# Moderation content warning wrapper #}
{% if item._moderated %}
{% if item._moderationReason == "muted_account" %}
{% set modLabel = __("activitypub.moderation.cwMutedAccount") %}
{% elif item._moderationReason == "muted_keyword" and item._moderationKeyword %}
{% set modLabel = __("activitypub.moderation.cwMutedKeyword") + ' "' + item._moderationKeyword + '"' %}
{% else %}
{% set modLabel = __("activitypub.moderation.cwFiltered") %}
{% endif %}
<div class="ap-card__moderation-cw" x-data="{ shown: false }">
<button @click="shown = !shown" class="ap-card__moderation-toggle">
<span x-show="!shown">🛡️ {{ modLabel }} — {{ __("activitypub.reader.showContent") }}</span>
<span x-show="shown" x-cloak>🛡️ {{ modLabel }} — {{ __("activitypub.reader.hideContent") }}</span>
</button>
<div x-show="shown" x-cloak>
{% endif %}
{# Boost header if this is a boosted post #}
{% if item.type == "boost" and item.boostedBy %}
<div class="ap-card__boost">
🔁 {% if item.boostedBy.url %}<a href="{{ mountPath }}/admin/reader/profile?url={{ item.boostedBy.url | urlencode }}">{% if item.boostedBy.nameHtml %}{{ item.boostedBy.nameHtml | safe }}{% else %}{{ item.boostedBy.name or "Someone" }}{% endif %}</a>{% else %}{% if item.boostedBy.nameHtml %}{{ item.boostedBy.nameHtml | safe }}{% else %}{{ item.boostedBy.name or "Someone" }}{% endif %}{% endif %} {{ __("activitypub.reader.boosted") }}
</div>
{% endif %}
{# Reply context if this is a reply #}
{% if item.inReplyTo %}
<div class="ap-card__reply-to">
↩ {{ __("activitypub.reader.replyingTo") }} <a href="{{ mountPath }}/admin/reader/post?url={{ item.inReplyTo | urlencode }}">{{ item.inReplyTo }}</a>
</div>
{% endif %}
{# Author header #}
<header class="ap-card__author">
<div class="ap-card__avatar-wrap" data-avatar-fallback>
{% if item.author.photo %}
<img src="{{ item.author.photo }}" alt="{{ item.author.name }}" class="ap-card__avatar" loading="lazy" crossorigin="anonymous">
{% endif %}
<span class="ap-card__avatar ap-card__avatar--default" aria-hidden="true">{{ item.author.name[0] | upper if item.author.name else "?" }}</span>
</div>
<div class="ap-card__author-info">
<div class="ap-card__author-name">
{% if item.author.url %}
<a href="{{ mountPath }}/admin/reader/profile?url={{ item.author.url | urlencode }}">{% if item.author.nameHtml %}{{ item.author.nameHtml | safe }}{% else %}{{ item.author.name or "Unknown" }}{% endif %}</a>
{% else %}
<span>{% if item.author.nameHtml %}{{ item.author.nameHtml | safe }}{% else %}{{ item.author.name or "Unknown" }}{% endif %}</span>
{% endif %}
{% if item.author.bot %}<span class="ap-card__bot-badge" title="Bot account">BOT</span>{% endif %}
</div>
{% if item.author.handle %}
<div class="ap-card__author-handle">{{ item.author.handle }}</div>
{% endif %}
</div>
{% if item.published %}
<a href="{{ mountPath }}/admin/reader/post?url={{ (item.uid or item.url) | urlencode }}" class="ap-card__timestamp-link" title="{{ __('activitypub.reader.post.title') }}">
<time datetime="{{ item.published }}" class="ap-card__timestamp" x-data x-relative-time>
{{ item.published | date("PPp") }}
</time>
{% if item.updated %}<span class="ap-card__edited" title="{{ item.updated | date('PPp') }}">✏️</span>{% endif %}
</a>
{% endif %}
</header>
{# Post title (articles only) #}
{% if item.name %}
<h2 class="ap-card__title">
<a href="{{ mountPath }}/admin/reader/post?url={{ item.uid | urlencode }}">{{ item.name }}</a>
</h2>
{% endif %}
{# Determine if content should be hidden behind CW #}
{% set hasCW = item.summary or item.sensitive %}
{% set cwLabel = item.summary if item.summary else __("activitypub.reader.sensitiveContent") %}
{% if hasCW %}
<div class="ap-card__cw" x-data="{ shown: false }">
<button @click="shown = !shown" class="ap-card__cw-toggle">
<span x-show="!shown">⚠️ {{ cwLabel }} — {{ __("activitypub.reader.showContent") }}</span>
<span x-show="shown" x-cloak>{{ __("activitypub.reader.hideContent") }}</span>
</button>
<div x-show="shown" x-cloak>
{% if item.content and item.content.html %}
<div class="ap-card__content">
{{ item.content.html | safe }}
</div>
{% endif %}
{# Quoted post embed #}
{% include "partials/ap-quote-embed.njk" %}
{# Link previews #}
{% include "partials/ap-link-preview.njk" %}
{# Media hidden behind CW #}
{% include "partials/ap-item-media.njk" %}
{# Poll options #}
{% if item.type == "question" or (item.pollOptions and item.pollOptions.length > 0) %}
{% include "partials/ap-poll-options.njk" %}
{% endif %}
</div>
</div>
{% else %}
{# Regular content (no CW) #}
{% if item.content and item.content.html %}
<div class="ap-card__content">
{{ item.content.html | safe }}
</div>
{% endif %}
{# Quoted post embed #}
{% include "partials/ap-quote-embed.njk" %}
{# Link previews #}
{% include "partials/ap-link-preview.njk" %}
{# Media visible directly #}
{% include "partials/ap-item-media.njk" %}
{# Poll options #}
{% if item.type == "question" or (item.pollOptions and item.pollOptions.length > 0) %}
{% include "partials/ap-poll-options.njk" %}
{% endif %}
{% endif %}
{# Mentions and hashtags #}
{% set hasMentions = item.mentions and item.mentions.length > 0 %}
{% set hasHashtags = item.category and item.category.length > 0 %}
{% if hasMentions or hasHashtags %}
<div class="ap-card__tags">
{# Mentions — render with @ prefix, link to profile view when URL available #}
{% if hasMentions %}
{% for mention in item.mentions %}
{% if mention.url %}
<a href="{{ mountPath }}/admin/reader/profile?url={{ mention.url | urlencode }}" class="ap-card__mention">@{{ mention.name }}</a>
{% else %}
<span class="ap-card__mention ap-card__mention--legacy">@{{ mention.name }}</span>
{% endif %}
{% endfor %}
{% endif %}
{# Hashtags — render with # prefix, link to tag timeline #}
{% if hasHashtags %}
{% for tag in item.category %}
<a href="{{ mountPath }}/admin/reader/tag?tag={{ tag | urlencode }}" class="ap-card__tag">#{{ tag }}</a>
{% endfor %}
{% endif %}
</div>
{% endif %}
{# Interaction buttons — Alpine.js for optimistic updates #}
{# Use canonical AP uid for interactions (Fedify lookupObject), display url for links #}
{% set itemUrl = item.url or item.originalUrl %}
{% set itemUid = item.uid or item.url or item.originalUrl %}
{% set isLiked = interactionMap[itemUid].like if interactionMap[itemUid] else false %}
{% set isBoosted = interactionMap[itemUid].boost if interactionMap[itemUid] else false %}
{% set replyCount = item.counts.replies if item.counts and item.counts.replies != null else null %}
{% set boostCount = item.counts.boosts if item.counts and item.counts.boosts != null else null %}
{% set likeCount = item.counts.likes if item.counts and item.counts.likes != null else null %}
<footer class="ap-card__actions"
data-item-uid="{{ itemUid }}"
data-item-url="{{ itemUrl }}"
data-csrf-token="{{ csrfToken }}"
data-mount-path="{{ mountPath }}"
x-data="{
liked: {{ 'true' if isLiked else 'false' }},
boosted: {{ 'true' if isBoosted else 'false' }},
saved: false,
loading: false,
error: '',
boostCount: {{ boostCount if boostCount != null else 'null' }},
likeCount: {{ likeCount if likeCount != null else 'null' }},
async saveLater() {
if (this.saved) return;
const el = this.$root;
const itemUrl = el.dataset.itemUrl;
try {
const res = await fetch('/readlater/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: itemUrl,
title: el.closest('article')?.querySelector('p')?.textContent?.substring(0, 80) || itemUrl,
source: 'activitypub'
}),
credentials: 'same-origin'
});
if (res.ok) this.saved = true;
else this.error = 'Failed to save';
} catch (e) {
this.error = e.message;
}
if (this.error) setTimeout(() => this.error = '', 3000);
},
async interact(action) {
if (this.loading) return;
this.loading = true;
this.error = '';
const el = this.$root;
const itemUid = el.dataset.itemUid;
const csrfToken = el.dataset.csrfToken;
const basePath = el.dataset.mountPath;
const prev = { liked: this.liked, boosted: this.boosted, boostCount: this.boostCount, likeCount: this.likeCount };
if (action === 'like') { this.liked = true; if (this.likeCount !== null) this.likeCount++; }
else if (action === 'unlike') { this.liked = false; if (this.likeCount !== null && this.likeCount > 0) this.likeCount--; }
else if (action === 'boost') { this.boosted = true; if (this.boostCount !== null) this.boostCount++; }
else if (action === 'unboost') { this.boosted = false; if (this.boostCount !== null && this.boostCount > 0) this.boostCount--; }
try {
const res = await fetch(basePath + '/admin/reader/' + action, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
body: JSON.stringify({ url: itemUid })
});
const data = await res.json();
if (!data.success) {
this.liked = prev.liked;
this.boosted = prev.boosted;
this.boostCount = prev.boostCount;
this.likeCount = prev.likeCount;
this.error = data.error || 'Failed';
}
} catch (e) {
this.liked = prev.liked;
this.boosted = prev.boosted;
this.boostCount = prev.boostCount;
this.likeCount = prev.likeCount;
this.error = e.message;
}
this.loading = false;
if (this.error) setTimeout(() => this.error = '', 3000);
}
}">
<a href="{{ mountPath }}/admin/reader/compose?replyTo={{ (itemUrl or itemUid) | urlencode }}"
class="ap-card__action ap-card__action--reply"
title="{{ __('activitypub.reader.actions.reply') }}">
↩ {{ __("activitypub.reader.actions.reply") }}{% if replyCount != null %}<span class="ap-card__count">{{ replyCount }}</span>{% endif %}
</a>
<button class="ap-card__action ap-card__action--boost"
:class="{ 'ap-card__action--active': boosted }"
:title="boosted ? '{{ __('activitypub.reader.actions.unboost') }}' : '{{ __('activitypub.reader.actions.boost') }}'"
:disabled="loading"
@click="interact(boosted ? 'unboost' : 'boost')">
🔁 <span x-text="boosted ? '{{ __('activitypub.reader.actions.boosted') }}' : '{{ __('activitypub.reader.actions.boost') }}'"></span><template x-if="boostCount !== null"><span class="ap-card__count" x-text="boostCount"></span></template>
</button>
<button class="ap-card__action ap-card__action--like"
:class="{ 'ap-card__action--active': liked }"
:title="liked ? '{{ __('activitypub.reader.actions.unlike') }}' : '{{ __('activitypub.reader.actions.like') }}'"
:disabled="loading"
@click="interact(liked ? 'unlike' : 'like')">
<span x-text="liked ? '❤️' : '♥'"></span>
<span x-text="liked ? '{{ __('activitypub.reader.actions.liked') }}' : '{{ __('activitypub.reader.actions.like') }}'"></span><template x-if="likeCount !== null"><span class="ap-card__count" x-text="likeCount"></span></template>
</button>
<a href="{{ itemUrl }}" class="ap-card__action ap-card__action--link" target="_blank" rel="noopener">
🔗 {{ __("activitypub.reader.actions.viewOriginal") }}
</a>
{% if application.readlaterEndpoint %}
<button class="ap-card__action ap-card__action--save"
:class="{ 'ap-card__action--active': saved }"
:disabled="saved"
@click="saveLater()"
:title="saved ? 'Saved' : 'Save for later'">
<span x-text="saved ? '🔖' : '📑'"></span>
<span x-text="saved ? 'Saved' : 'Save'"></span>
</button>
{% endif %}
<div x-show="error" x-text="error" class="ap-card__action-error" x-transition></div>
</footer>
{# Close moderation content warning wrapper #}
{% if item._moderated %}
</div>{# /x-show="shown" #}
</div>{# /ap-card__moderation-cw #}
{% endif %}
</article>
{% endif %}{# end hasCardContent/hasCardTitle/hasCardMedia guard #}