mirror of
https://github.com/jointakahe/takahe.git
synced 2024-11-29 02:31:00 +00:00
Post types (#182)
Initial support for Posts of type other than 'Note'. Render special Post types with templates.
This commit is contained in:
parent
86bc48f3e0
commit
a408cbaa27
9 changed files with 330 additions and 19 deletions
|
@ -104,8 +104,8 @@ class PostAttachmentInline(admin.StackedInline):
|
||||||
|
|
||||||
@admin.register(Post)
|
@admin.register(Post)
|
||||||
class PostAdmin(admin.ModelAdmin):
|
class PostAdmin(admin.ModelAdmin):
|
||||||
list_display = ["id", "state", "author", "created"]
|
list_display = ["id", "type", "author", "state", "created"]
|
||||||
list_filter = ("local", "visibility", "state", "created")
|
list_filter = ("type", "local", "visibility", "state", "created")
|
||||||
raw_id_fields = ["to", "mentions", "author", "emojis"]
|
raw_id_fields = ["to", "mentions", "author", "emojis"]
|
||||||
actions = ["force_fetch", "reparse_hashtags"]
|
actions = ["force_fetch", "reparse_hashtags"]
|
||||||
search_fields = ["content"]
|
search_fields = ["content"]
|
||||||
|
|
36
activities/migrations/0005_post_type_post_type_data.py
Normal file
36
activities/migrations/0005_post_type_post_type_data.py
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
# Generated by Django 4.1.4 on 2022-12-16 02:52
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("activities", "0004_emoji_post_emojis"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="post",
|
||||||
|
name="type",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("Article", "Article"),
|
||||||
|
("Audio", "Audio"),
|
||||||
|
("Event", "Event"),
|
||||||
|
("Image", "Image"),
|
||||||
|
("Note", "Note"),
|
||||||
|
("Page", "Page"),
|
||||||
|
("Question", "Question"),
|
||||||
|
("Video", "Video"),
|
||||||
|
],
|
||||||
|
default="Note",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="post",
|
||||||
|
name="type_data",
|
||||||
|
field=models.JSONField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
|
@ -8,6 +8,7 @@ from asgiref.sync import async_to_sync, sync_to_async
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.postgres.indexes import GinIndex
|
from django.contrib.postgres.indexes import GinIndex
|
||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
|
from django.template import loader
|
||||||
from django.template.defaultfilters import linebreaks_filter
|
from django.template.defaultfilters import linebreaks_filter
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
|
@ -15,6 +16,11 @@ from django.utils.safestring import mark_safe
|
||||||
from activities.models.emoji import Emoji
|
from activities.models.emoji import Emoji
|
||||||
from activities.models.fan_out import FanOut
|
from activities.models.fan_out import FanOut
|
||||||
from activities.models.hashtag import Hashtag
|
from activities.models.hashtag import Hashtag
|
||||||
|
from activities.models.post_types import (
|
||||||
|
PostTypeData,
|
||||||
|
PostTypeDataDecoder,
|
||||||
|
PostTypeDataEncoder,
|
||||||
|
)
|
||||||
from activities.templatetags.emoji_tags import imageify_emojis
|
from activities.templatetags.emoji_tags import imageify_emojis
|
||||||
from core.html import sanitize_post, strip_html
|
from core.html import sanitize_post, strip_html
|
||||||
from core.ld import canonicalise, format_ld_date, get_list, parse_ld_date
|
from core.ld import canonicalise, format_ld_date, get_list, parse_ld_date
|
||||||
|
@ -166,6 +172,16 @@ class Post(StatorModel):
|
||||||
followers = 2
|
followers = 2
|
||||||
mentioned = 3
|
mentioned = 3
|
||||||
|
|
||||||
|
class Types(models.TextChoices):
|
||||||
|
article = "Article"
|
||||||
|
audio = "Audio"
|
||||||
|
event = "Event"
|
||||||
|
image = "Image"
|
||||||
|
note = "Note"
|
||||||
|
page = "Page"
|
||||||
|
question = "Question"
|
||||||
|
video = "Video"
|
||||||
|
|
||||||
# The author (attributedTo) of the post
|
# The author (attributedTo) of the post
|
||||||
author = models.ForeignKey(
|
author = models.ForeignKey(
|
||||||
"users.Identity",
|
"users.Identity",
|
||||||
|
@ -191,6 +207,15 @@ class Post(StatorModel):
|
||||||
# The main (HTML) content
|
# The main (HTML) content
|
||||||
content = models.TextField()
|
content = models.TextField()
|
||||||
|
|
||||||
|
type = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=Types.choices,
|
||||||
|
default=Types.note,
|
||||||
|
)
|
||||||
|
type_data = models.JSONField(
|
||||||
|
blank=True, null=True, encoder=PostTypeDataEncoder, decoder=PostTypeDataDecoder
|
||||||
|
)
|
||||||
|
|
||||||
# If the contents of the post are sensitive, and the summary (content
|
# If the contents of the post are sensitive, and the summary (content
|
||||||
# warning) to show if it is
|
# warning) to show if it is
|
||||||
sensitive = models.BooleanField(default=False)
|
sensitive = models.BooleanField(default=False)
|
||||||
|
@ -292,6 +317,8 @@ class Post(StatorModel):
|
||||||
ain_reply_to_post = sync_to_async(in_reply_to_post)
|
ain_reply_to_post = sync_to_async(in_reply_to_post)
|
||||||
|
|
||||||
### Content cleanup and extraction ###
|
### Content cleanup and extraction ###
|
||||||
|
def clean_type_data(self, value):
|
||||||
|
PostTypeData.parse_obj(value)
|
||||||
|
|
||||||
mention_regex = re.compile(
|
mention_regex = re.compile(
|
||||||
r"(^|[^\w\d\-_])@([\w\d\-_]+(?:@[\w\d\-_]+\.[\w\d\-_\.]+)?)"
|
r"(^|[^\w\d\-_])@([\w\d\-_]+(?:@[\w\d\-_]+\.[\w\d\-_\.]+)?)"
|
||||||
|
@ -333,25 +360,55 @@ class Post(StatorModel):
|
||||||
|
|
||||||
return mark_safe(self.mention_regex.sub(replacer, content))
|
return mark_safe(self.mention_regex.sub(replacer, content))
|
||||||
|
|
||||||
|
def _safe_content_note(self, *, local: bool = True):
|
||||||
|
content = Hashtag.linkify_hashtags(
|
||||||
|
self.linkify_mentions(sanitize_post(self.content), local=local),
|
||||||
|
domain=self.author.domain,
|
||||||
|
)
|
||||||
|
if local:
|
||||||
|
content = imageify_emojis(content, self.author.domain)
|
||||||
|
return content
|
||||||
|
|
||||||
|
# def _safe_content_question(self, *, local: bool = True):
|
||||||
|
# context = {
|
||||||
|
# "post": self,
|
||||||
|
# "typed_data": PostTypeData(self.type_data),
|
||||||
|
# }
|
||||||
|
# return loader.render_to_string("activities/_type_question.html", context)
|
||||||
|
|
||||||
|
def _safe_content_typed(self, *, local: bool = True):
|
||||||
|
context = {
|
||||||
|
"post": self,
|
||||||
|
"sanitized_content": self._safe_content_note(local=local),
|
||||||
|
"local_display": local,
|
||||||
|
}
|
||||||
|
return loader.render_to_string(
|
||||||
|
(
|
||||||
|
f"activities/_type_{self.type.lower()}.html",
|
||||||
|
"activities/_type_unknown.html",
|
||||||
|
),
|
||||||
|
context,
|
||||||
|
)
|
||||||
|
|
||||||
|
def safe_content(self, *, local: bool = True):
|
||||||
|
func = getattr(
|
||||||
|
self, f"_safe_content_{self.type.lower()}", self._safe_content_typed
|
||||||
|
)
|
||||||
|
if callable(func):
|
||||||
|
return func(local=local)
|
||||||
|
return self._safe_content_note(local=local) # fallback
|
||||||
|
|
||||||
def safe_content_local(self):
|
def safe_content_local(self):
|
||||||
"""
|
"""
|
||||||
Returns the content formatted for local display
|
Returns the content formatted for local display
|
||||||
"""
|
"""
|
||||||
return imageify_emojis(
|
return self.safe_content(local=True)
|
||||||
Hashtag.linkify_hashtags(
|
|
||||||
self.linkify_mentions(sanitize_post(self.content), local=True)
|
|
||||||
),
|
|
||||||
self.author.domain,
|
|
||||||
)
|
|
||||||
|
|
||||||
def safe_content_remote(self):
|
def safe_content_remote(self):
|
||||||
"""
|
"""
|
||||||
Returns the content formatted for remote consumption
|
Returns the content formatted for remote consumption
|
||||||
"""
|
"""
|
||||||
return Hashtag.linkify_hashtags(
|
return self.safe_content(local=False)
|
||||||
self.linkify_mentions(sanitize_post(self.content)),
|
|
||||||
domain=self.author.domain,
|
|
||||||
)
|
|
||||||
|
|
||||||
def safe_content_plain(self):
|
def safe_content_plain(self):
|
||||||
"""
|
"""
|
||||||
|
@ -521,7 +578,7 @@ class Post(StatorModel):
|
||||||
value = {
|
value = {
|
||||||
"to": "Public",
|
"to": "Public",
|
||||||
"cc": [],
|
"cc": [],
|
||||||
"type": "Note",
|
"type": self.type,
|
||||||
"id": self.object_uri,
|
"id": self.object_uri,
|
||||||
"published": format_ld_date(self.published),
|
"published": format_ld_date(self.published),
|
||||||
"attributedTo": self.author.actor_uri,
|
"attributedTo": self.author.actor_uri,
|
||||||
|
@ -531,6 +588,18 @@ class Post(StatorModel):
|
||||||
"tag": [],
|
"tag": [],
|
||||||
"attachment": [],
|
"attachment": [],
|
||||||
}
|
}
|
||||||
|
if self.type == Post.Types.question and self.type_data:
|
||||||
|
value[self.type_data.mode] = [
|
||||||
|
{
|
||||||
|
"name": option.name,
|
||||||
|
"type": option.type,
|
||||||
|
"replies": {"type": "Collection", "totalItems": option.votes},
|
||||||
|
}
|
||||||
|
for option in self.type_data.options
|
||||||
|
]
|
||||||
|
value["toot:votersCount"] = self.type_data.voter_count
|
||||||
|
if self.type_data.end_time:
|
||||||
|
value["endTime"] = format_ld_date(self.type_data.end_time)
|
||||||
if self.summary:
|
if self.summary:
|
||||||
value["summary"] = self.summary
|
value["summary"] = self.summary
|
||||||
if self.in_reply_to:
|
if self.in_reply_to:
|
||||||
|
@ -660,11 +729,16 @@ class Post(StatorModel):
|
||||||
author=author,
|
author=author,
|
||||||
content=data["content"],
|
content=data["content"],
|
||||||
local=False,
|
local=False,
|
||||||
|
type=data["type"],
|
||||||
)
|
)
|
||||||
created = True
|
created = True
|
||||||
else:
|
else:
|
||||||
raise cls.DoesNotExist(f"No post with ID {data['id']}", data)
|
raise cls.DoesNotExist(f"No post with ID {data['id']}", data)
|
||||||
if update or created:
|
if update or created:
|
||||||
|
post.type = data["type"]
|
||||||
|
if post.type in (cls.Types.article, cls.Types.question):
|
||||||
|
type_data = PostTypeData(__root__=data).__root__
|
||||||
|
post.type_data = type_data.dict()
|
||||||
post.content = data["content"]
|
post.content = data["content"]
|
||||||
post.summary = data.get("summary")
|
post.summary = data.get("summary")
|
||||||
post.sensitive = data.get("sensitive", False)
|
post.sensitive = data.get("sensitive", False)
|
||||||
|
|
69
activities/models/post_types.py
Normal file
69
activities/models/post_types.py
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class BasePostDataType(BaseModel):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class QuestionOption(BaseModel):
|
||||||
|
name: str
|
||||||
|
type: Literal["Note"] = "Note"
|
||||||
|
votes: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class QuestionData(BasePostDataType):
|
||||||
|
type: Literal["Question"]
|
||||||
|
mode: Literal["oneOf", "anyOf"] | None = None
|
||||||
|
options: list[QuestionOption] | None
|
||||||
|
voter_count: int = Field(alias="http://joinmastodon.org/ns#votersCount", default=0)
|
||||||
|
end_time: datetime | None = Field(alias="endTime")
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
extra = "ignore"
|
||||||
|
allow_population_by_field_name = True
|
||||||
|
|
||||||
|
def __init__(self, **data) -> None:
|
||||||
|
if "mode" not in data:
|
||||||
|
data["mode"] = "anyOf" if "anyOf" in data else "oneOf"
|
||||||
|
if "options" not in data:
|
||||||
|
options = data.pop("anyOf", None)
|
||||||
|
if not options:
|
||||||
|
options = data.pop("oneOf", None)
|
||||||
|
data["options"] = options
|
||||||
|
super().__init__(**data)
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleData(BasePostDataType):
|
||||||
|
type: Literal["Article"]
|
||||||
|
attributed_to: str | None = Field(...)
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
extra = "ignore"
|
||||||
|
|
||||||
|
|
||||||
|
PostDataType = QuestionData | ArticleData
|
||||||
|
|
||||||
|
|
||||||
|
class PostTypeData(BaseModel):
|
||||||
|
__root__: PostDataType = Field(discriminator="type")
|
||||||
|
|
||||||
|
|
||||||
|
class PostTypeDataEncoder(json.JSONEncoder):
|
||||||
|
def default(self, obj):
|
||||||
|
if isinstance(obj, BasePostDataType):
|
||||||
|
return obj.dict()
|
||||||
|
elif isinstance(obj, datetime):
|
||||||
|
return obj.isoformat()
|
||||||
|
return json.JSONEncoder.default(self, obj)
|
||||||
|
|
||||||
|
|
||||||
|
class PostTypeDataDecoder(json.JSONDecoder):
|
||||||
|
def decode(self, *args, **kwargs):
|
||||||
|
s = super().decode(*args, **kwargs)
|
||||||
|
if isinstance(s, dict):
|
||||||
|
return PostTypeData.parse_obj(s).__root__
|
||||||
|
return s
|
|
@ -1087,6 +1087,36 @@ table.metadata td .emoji {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Special Type Posts */
|
||||||
|
.post .notice a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post .poll h3 small {
|
||||||
|
font-weight: lighter;
|
||||||
|
font-size: small;
|
||||||
|
}
|
||||||
|
.post .poll ul {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post .poll li {
|
||||||
|
padding: 6px 0;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
.poll-number {
|
||||||
|
display: inline-block;
|
||||||
|
width: 45px;
|
||||||
|
}
|
||||||
|
.poll-footer {
|
||||||
|
padding: 6px 0 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.post .poll ul li .votes {
|
||||||
|
margin-left: 10px;
|
||||||
|
font-size: small;
|
||||||
|
}
|
||||||
|
|
||||||
.boost-banner,
|
.boost-banner,
|
||||||
.mention-banner,
|
.mention-banner,
|
||||||
.follow-banner,
|
.follow-banner,
|
||||||
|
|
22
templates/activities/_type_question.html
Normal file
22
templates/activities/_type_question.html
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
{% load activity_tags %}
|
||||||
|
|
||||||
|
{{ sanitized_content }}
|
||||||
|
|
||||||
|
<div class="poll">
|
||||||
|
<h3 style="display: none;">Options: {% if post.type_data.mode == "oneOf" %}<small>(choose one)</small>{% endif %}</h3>
|
||||||
|
{% for item in post.type_data.options %}
|
||||||
|
{% if forloop.first %}<ul>{% endif %}{% widthratio item.votes post.type_data.voter_count 100 as item_percent %}
|
||||||
|
<li><label class="poll-option">
|
||||||
|
<input style="display:none" name="vote-options" type="{% if post.type_data.mode == "oneOf" %}radio{% else %}checkbox{% endif %}" value="0">
|
||||||
|
<span class="poll-number" title="{{ item.votes }} votes">{{ item_percent }}%</span>
|
||||||
|
<span class="poll-option-text">{{ item.name }}</span>
|
||||||
|
</label>
|
||||||
|
{% if forloop.last %}</ul>{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<div class="poll-footer">
|
||||||
|
<span class="vote-total">{{ post.type_data.voter_count }} people</span>
|
||||||
|
—
|
||||||
|
{% if post.type_data.end_time %}<span class="vote-end">{{ post.type_data.end_time|timedeltashort }}</span>{% endif %}
|
||||||
|
<span>Polls are currently display only</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
5
templates/activities/_type_unknown.html
Normal file
5
templates/activities/_type_unknown.html
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
{{ sanitized_content }}
|
||||||
|
|
||||||
|
<small class="notice" x-post-type-data="{{ post.type_data|escape }}">
|
||||||
|
Takahe has limited support for this type: <a href="{{ post.url }}">See Original {{ post.type }}</a>
|
||||||
|
</small>
|
69
tests/activities/models/test_post_types.py
Normal file
69
tests/activities/models/test_post_types.py
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from activities.models import Post
|
||||||
|
from activities.models.post_types import QuestionData
|
||||||
|
from core.ld import canonicalise
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_question_post(config_system, identity, remote_identity):
|
||||||
|
data = {
|
||||||
|
"cc": [],
|
||||||
|
"id": "https://fosstodon.org/users/manfre/statuses/109519951621804608/activity",
|
||||||
|
"to": identity.absolute_profile_uri(),
|
||||||
|
"type": "Create",
|
||||||
|
"actor": "https://fosstodon.org/users/manfre",
|
||||||
|
"object": {
|
||||||
|
"cc": [],
|
||||||
|
"id": "https://fosstodon.org/users/manfre/statuses/109519951621804608",
|
||||||
|
"to": identity.absolute_profile_uri(),
|
||||||
|
"tag": [],
|
||||||
|
"url": "https://fosstodon.org/@manfre/109519951621804608",
|
||||||
|
"type": "Question",
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"name": "Option 1",
|
||||||
|
"type": "Note",
|
||||||
|
"replies": {"type": "Collection", "totalItems": 0},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Option 2",
|
||||||
|
"type": "Note",
|
||||||
|
"replies": {"type": "Collection", "totalItems": 0},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"content": '<p>This is a poll :python: </p><p><span class="h-card"><a href="https://ehakat.manfre.net/@mike/" class="u-url mention">@<span>mike</span></a></span></p>',
|
||||||
|
"endTime": "2022-12-18T22:03:59Z",
|
||||||
|
"replies": {
|
||||||
|
"id": "https://fosstodon.org/users/manfre/statuses/109519951621804608/replies",
|
||||||
|
"type": "Collection",
|
||||||
|
"first": {
|
||||||
|
"next": "https://fosstodon.org/users/manfre/statuses/109519951621804608/replies?only_other_accounts=true&page=true",
|
||||||
|
"type": "CollectionPage",
|
||||||
|
"items": [],
|
||||||
|
"partOf": "https://fosstodon.org/users/manfre/statuses/109519951621804608/replies",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"published": "2022-12-15T22:03:59Z",
|
||||||
|
"attachment": [],
|
||||||
|
"contentMap": {
|
||||||
|
"en": '<p>This is a poll :python: </p><p><span class="h-card"><a href="https://ehakat.manfre.net/@mike/" class="u-url mention">@<span>mike</span></a></span></p>'
|
||||||
|
},
|
||||||
|
"as:sensitive": False,
|
||||||
|
"attributedTo": "https://fosstodon.org/users/manfre",
|
||||||
|
"http://ostatus.org#atomUri": "https://fosstodon.org/users/manfre/statuses/109519951621804608",
|
||||||
|
"http://ostatus.org#conversation": "tag:fosstodon.org,2022-12-15:objectId=69494364:objectType=Conversation",
|
||||||
|
"http://joinmastodon.org/ns#votersCount": 0,
|
||||||
|
},
|
||||||
|
"@context": [
|
||||||
|
"https://www.w3.org/ns/activitystreams",
|
||||||
|
"https://w3id.org/security/v1",
|
||||||
|
],
|
||||||
|
"published": "2022-12-15T22:03:59Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
post = Post.by_ap(
|
||||||
|
data=canonicalise(data["object"], include_security=True), create=True
|
||||||
|
)
|
||||||
|
assert post.type == Post.Types.question
|
||||||
|
QuestionData.parse_obj(post.type_data)
|
|
@ -32,9 +32,12 @@ class InboxMessageStates(StateGraph):
|
||||||
case "question":
|
case "question":
|
||||||
pass # Drop for now
|
pass # Drop for now
|
||||||
case unknown:
|
case unknown:
|
||||||
raise ValueError(
|
if unknown in Post.Types.names:
|
||||||
f"Cannot handle activity of type create.{unknown}"
|
await sync_to_async(Post.handle_create_ap)(instance.message)
|
||||||
)
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
f"Cannot handle activity of type create.{unknown}"
|
||||||
|
)
|
||||||
case "update":
|
case "update":
|
||||||
match instance.message_object_type:
|
match instance.message_object_type:
|
||||||
case "note":
|
case "note":
|
||||||
|
@ -44,9 +47,12 @@ class InboxMessageStates(StateGraph):
|
||||||
case "question":
|
case "question":
|
||||||
pass # Drop for now
|
pass # Drop for now
|
||||||
case unknown:
|
case unknown:
|
||||||
raise ValueError(
|
if unknown in Post.Types.names:
|
||||||
f"Cannot handle activity of type update.{unknown}"
|
await sync_to_async(Post.handle_update_ap)(instance.message)
|
||||||
)
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
f"Cannot handle activity of type update.{unknown}"
|
||||||
|
)
|
||||||
case "accept":
|
case "accept":
|
||||||
match instance.message_object_type:
|
match instance.message_object_type:
|
||||||
case "follow":
|
case "follow":
|
||||||
|
|
Loading…
Reference in a new issue