From 9759a97463fe58dec78ed6a7b103b477b581e27f Mon Sep 17 00:00:00 2001 From: Dan Watson Date: Tue, 16 Apr 2024 23:43:19 -0400 Subject: [PATCH 1/3] Implement markers API --- .gitignore | 1 + api/schemas.py | 13 +++++++++++ api/urls.py | 9 ++++++++ api/views/markers.py | 36 +++++++++++++++++++++++++++++ api/views/oauth.py | 20 +++------------- core/middleware.py | 23 ++++++++++++++++++ docs/features.rst | 5 ++-- docs/releases/next.rst | 7 ++++++ takahe/settings.py | 1 + users/admin.py | 6 +++++ users/migrations/0023_marker.py | 41 +++++++++++++++++++++++++++++++++ users/models/__init__.py | 1 + users/models/marker.py | 31 +++++++++++++++++++++++++ 13 files changed, 175 insertions(+), 19 deletions(-) create mode 100644 api/views/markers.py create mode 100644 users/migrations/0023_marker.py create mode 100644 users/models/marker.py diff --git a/.gitignore b/.gitignore index 31926cca5..0014bfa82 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ *.sqlite3 .DS_Store .idea/* +.nova .venv .vscode /*.env* diff --git a/api/schemas.py b/api/schemas.py index 0f0441c9c..ca6d51639 100644 --- a/api/schemas.py +++ b/api/schemas.py @@ -503,3 +503,16 @@ def from_token( return value else: return None + + +class Marker(Schema): + last_read_id: str + version: int + updated_at: str + + @classmethod + def from_marker( + cls, + marker: users_models.Marker, + ) -> "Marker": + return cls(**marker.to_mastodon_json()) diff --git a/api/urls.py b/api/urls.py index 842aec809..65667a186 100644 --- a/api/urls.py +++ b/api/urls.py @@ -11,6 +11,7 @@ follow_requests, instance, lists, + markers, media, notifications, polls, @@ -67,6 +68,14 @@ path("v2/instance", instance.instance_info_v2), # Lists path("v1/lists", lists.get_lists), + # Markers + path( + "v1/markers", + methods( + get=markers.markers, + post=markers.set_markers, + ), + ), # Media path("v1/media", media.upload_media), path("v2/media", media.upload_media), diff --git a/api/views/markers.py b/api/views/markers.py new file mode 100644 index 000000000..1af22ef0e --- /dev/null +++ b/api/views/markers.py @@ -0,0 +1,36 @@ +from django.http import HttpRequest +from hatchway import api_view + +from api import schemas +from api.decorators import scope_required + + +@scope_required("read:statuses") +@api_view.get +def markers(request: HttpRequest) -> dict[str, schemas.Marker]: + timelines = set(request.PARAMS.getlist("timeline[]")) + data = {} + for m in request.identity.markers.filter(timeline__in=timelines): + data[m.timeline] = schemas.Marker.from_marker(m) + return data + + +@scope_required("write:statuses") +@api_view.post +def set_markers(request: HttpRequest) -> dict[str, schemas.Marker]: + markers = {} + for key, last_id in request.PARAMS.items(): + if not key.endswith("[last_read_id]"): + continue + timeline = key.replace("[last_read_id]", "") + marker, created = request.identity.markers.get_or_create( + timeline=timeline, + defaults={ + "last_read_id": last_id, + }, + ) + if not created: + marker.last_read_id = last_id + marker.save() + markers[timeline] = schemas.Marker.from_marker(marker) + return markers diff --git a/api/views/oauth.py b/api/views/oauth.py index a7d67d36c..51fd893ff 100644 --- a/api/views/oauth.py +++ b/api/views/oauth.py @@ -1,5 +1,4 @@ import base64 -import json import secrets import time from urllib.parse import urlparse, urlunparse @@ -41,19 +40,6 @@ def __init__(self, redirect_uri, **kwargs): super().__init__(urlunparse(url_parts)) -def get_json_and_formdata(request): - # Did they submit JSON? - if request.content_type == "application/json" and request.body.strip(): - return json.loads(request.body) - # Fall back to form data - value = {} - for key, item in request.POST.items(): - value[key] = item - for key, item in request.GET.items(): - value[key] = item - return value - - class AuthorizationView(LoginRequiredMixin, View): """ Asks the user to authorize access. @@ -106,7 +92,7 @@ def get(self, request): return render(request, "api/oauth_authorize.html", context) def post(self, request): - post_data = get_json_and_formdata(request) + post_data = request.PARAMS # Grab the application and other details again redirect_uri = post_data["redirect_uri"] scope = post_data["scope"] @@ -160,7 +146,7 @@ def verify_code( ) def post(self, request): - post_data = get_json_and_formdata(request) + post_data = request.PARAMS.copy() auth_client_id, auth_client_secret = extract_client_info_from_basic_auth( request ) @@ -243,7 +229,7 @@ def post(self, request): @method_decorator(csrf_exempt, name="dispatch") class RevokeTokenView(View): def post(self, request): - post_data = get_json_and_formdata(request) + post_data = request.PARAMS.copy() auth_client_id, auth_client_secret = extract_client_info_from_basic_auth( request ) diff --git a/core/middleware.py b/core/middleware.py index db605598f..412162314 100644 --- a/core/middleware.py +++ b/core/middleware.py @@ -1,3 +1,4 @@ +import json from time import time from django.conf import settings @@ -73,3 +74,25 @@ def show_toolbar(request): Determines whether to show the debug toolbar on a given page. """ return settings.DEBUG and request.user.is_authenticated and request.user.admin + + +class ParamsMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def make_params(self, request): + # See https://docs.joinmastodon.org/client/intro/#parameters + # If they sent JSON, use that. + if request.content_type == "application/json" and request.body.strip(): + return json.loads(request.body) + # Otherwise, fall back to form data. + params = {} + for key, value in request.GET.items(): + params[key] = value + for key, value in request.POST.items(): + params[key] = value + return params + + def __call__(self, request): + request.PARAMS = self.make_params(request) + return self.get_response(request) diff --git a/docs/features.rst b/docs/features.rst index 0cbaf69ef..b1fc9954f 100644 --- a/docs/features.rst +++ b/docs/features.rst @@ -9,6 +9,7 @@ Currently, it supports: * A web UI (which can be installed as a PWA as well) * Mastodon-compatible client applications (beta support) * Posts with content warnings and visibilities including a local-only option +* Creating polls on posts * Editing post content * Viewing images, videos and other post attachments * Uploading images and attaching image captions @@ -28,6 +29,8 @@ Currently, it supports: * Server defederation (blocking) * Signup flow, including auto-cap by user numbers and invite system * Password reset via email +* Bookmarks +# Markers Features planned for releases up to 1.0: @@ -41,9 +44,7 @@ Features planned for releases up to 1.0: Features that may make it into 1.0, or might be further out: -* Creating polls on posts * Filters -* Bookmarks * Lists * Scheduling posts * Mastodon-compatible account migration target/source diff --git a/docs/releases/next.rst b/docs/releases/next.rst index 207ff69ce..cae7bd41f 100644 --- a/docs/releases/next.rst +++ b/docs/releases/next.rst @@ -13,3 +13,10 @@ variables. You can generate a keypair via `https://web-push-codelab.glitch.me/`_ Note that users of apps may need to sign out and in again to their accounts for the app to notice that it can now do push notifications. Some apps, like Elk, may cache the fact your server didn't support it for a while. + + +Marker Support +~~~~~~~~~~~~~~ + +Takahē now supports the `Markers API `_, +used by clients to sync read positions within timelines. diff --git a/takahe/settings.py b/takahe/settings.py index 2bd43b2da..6fc9f9aed 100644 --- a/takahe/settings.py +++ b/takahe/settings.py @@ -233,6 +233,7 @@ class Config: "django_htmx.middleware.HtmxMiddleware", "core.middleware.HeadersMiddleware", "core.middleware.ConfigLoadingMiddleware", + "core.middleware.ParamsMiddleware", "api.middleware.ApiTokenMiddleware", "users.middleware.DomainMiddleware", ] diff --git a/users/admin.py b/users/admin.py index 4d920f272..6ec0e279c 100644 --- a/users/admin.py +++ b/users/admin.py @@ -13,6 +13,7 @@ Identity, InboxMessage, Invite, + Marker, PasswordReset, Report, User, @@ -212,6 +213,11 @@ class InviteAdmin(admin.ModelAdmin): list_display = ["id", "created", "token", "note"] +@admin.register(Marker) +class MarkerAdmin(admin.ModelAdmin): + list_display = ["id", "identity", "timeline", "last_read_id", "updated_at"] + + @admin.register(Report) class ReportAdmin(admin.ModelAdmin): list_display = ["id", "created", "resolved", "type", "subject_identity"] diff --git a/users/migrations/0023_marker.py b/users/migrations/0023_marker.py new file mode 100644 index 000000000..32d8e1e02 --- /dev/null +++ b/users/migrations/0023_marker.py @@ -0,0 +1,41 @@ +# Generated by Django 4.2.11 on 2024-04-17 03:09 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("users", "0022_follow_request"), + ] + + operations = [ + migrations.CreateModel( + name="Marker", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("timeline", models.CharField(max_length=100)), + ("last_read_id", models.CharField(max_length=200)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "identity", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="markers", + to="users.identity", + ), + ), + ], + options={ + "unique_together": {("identity", "timeline")}, + }, + ), + ] diff --git a/users/models/__init__.py b/users/models/__init__.py index 8396e424e..c0a91d026 100644 --- a/users/models/__init__.py +++ b/users/models/__init__.py @@ -7,6 +7,7 @@ from .identity import Identity, IdentityStates # noqa from .inbox_message import InboxMessage, InboxMessageStates # noqa from .invite import Invite # noqa +from .marker import Marker # noqa from .password_reset import PasswordReset # noqa from .report import Report # noqa from .system_actor import SystemActor # noqa diff --git a/users/models/marker.py b/users/models/marker.py new file mode 100644 index 000000000..f18bedb7d --- /dev/null +++ b/users/models/marker.py @@ -0,0 +1,31 @@ +from django.db import models + +from core.ld import format_ld_date + + +class Marker(models.Model): + """ + A timeline marker. + """ + + identity = models.ForeignKey( + "users.Identity", + on_delete=models.CASCADE, + related_name="markers", + ) + timeline = models.CharField(max_length=100) + last_read_id = models.CharField(max_length=200) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = [("identity", "timeline")] + + def __str__(self): + return f"#{self.id}: {self.identity} → {self.timeline}[{self.last_read_id}]" + + def to_mastodon_json(self): + return { + "last_read_id": self.last_read_id, + "version": 0, + "updated_at": format_ld_date(self.updated_at), + } From ab86b1cbeed88a79ea96938455246dc21336c704 Mon Sep 17 00:00:00 2001 From: Dan Watson Date: Fri, 19 Apr 2024 23:14:53 -0400 Subject: [PATCH 2/3] Lists --- activities/services/post.py | 13 ++++- activities/services/timeline.py | 30 ++++++++++- api/schemas.py | 14 ++++-- api/urls.py | 26 +++++++++- api/views/accounts.py | 12 +++++ api/views/lists.py | 89 +++++++++++++++++++++++++++++++-- api/views/timelines.py | 29 +++++++++++ users/admin.py | 7 +++ users/migrations/0024_list.py | 54 ++++++++++++++++++++ users/models/__init__.py | 1 + users/models/lists.py | 37 ++++++++++++++ 11 files changed, 300 insertions(+), 12 deletions(-) create mode 100644 users/migrations/0024_list.py create mode 100644 users/models/lists.py diff --git a/activities/services/post.py b/activities/services/post.py index dbfc837b4..035498f1e 100644 --- a/activities/services/post.py +++ b/activities/services/post.py @@ -1,5 +1,7 @@ import logging +from django.db.models import OuterRef + from activities.models import ( Post, PostInteraction, @@ -18,11 +20,11 @@ class PostService: """ @classmethod - def queryset(cls): + def queryset(cls, include_reply_to_author=False): """ Returns the base queryset to use for fetching posts efficiently. """ - return ( + qs = ( Post.objects.not_hidden() .prefetch_related( "attachments", @@ -34,6 +36,13 @@ def queryset(cls): "author__domain", ) ) + if include_reply_to_author: + qs = qs.annotate( + in_reply_to_author_id=Post.objects.filter( + object_uri=OuterRef("in_reply_to") + ).values("author_id")[:1] + ) + return qs def __init__(self, post: Post): self.post = post diff --git a/activities/services/timeline.py b/activities/services/timeline.py index 925606a6b..d73cd0275 100644 --- a/activities/services/timeline.py +++ b/activities/services/timeline.py @@ -8,7 +8,8 @@ TimelineEvent, ) from activities.services import PostService -from users.models import Identity +from users.models import Identity, List +from users.services import IdentityService class TimelineService: @@ -152,3 +153,30 @@ def bookmarks(self) -> models.QuerySet[Post]: .filter(bookmarks__identity=self.identity) .order_by("-id") ) + + def for_list(self, alist: List) -> models.QuerySet[Post]: + """ + Return posts from members of `alist`, filtered by the lists replies policy. + """ + assert self.identity # Appease mypy + # We only need to include this if we need to filter on it. + include_author = alist.replies_policy == "followed" + members = alist.members.all() + queryset = PostService.queryset(include_reply_to_author=include_author) + match alist.replies_policy: + case "list": + # The default is to show posts (and replies) from list members. + criteria = models.Q(author__in=members) + case "none": + # Don't show any replies, just original posts from list members. + criteria = models.Q(author__in=members) & models.Q( + in_reply_to__isnull=True + ) + case "followed": + # Show posts from list members OR from accounts you follow replying to + # posts by list members. + criteria = models.Q(author__in=members) | ( + models.Q(author__in=IdentityService(self.identity).following()) + & models.Q(in_reply_to_author_id__in=members) + ) + return queryset.filter(criteria).order_by("-id") diff --git a/api/schemas.py b/api/schemas.py index ca6d51639..07d95f845 100644 --- a/api/schemas.py +++ b/api/schemas.py @@ -407,11 +407,15 @@ def from_announcement( class List(Schema): id: str title: str - replies_policy: Literal[ - "followed", - "list", - "none", - ] + replies_policy: Literal["followed", "list", "none"] + exclusive: bool + + @classmethod + def from_list( + cls, + list_instance: users_models.List, + ) -> "List": + return cls(**list_instance.to_mastodon_json()) class Preferences(Schema): diff --git a/api/urls.py b/api/urls.py index 65667a186..be83e8872 100644 --- a/api/urls.py +++ b/api/urls.py @@ -44,6 +44,7 @@ path("v1/accounts//following", accounts.account_following), path("v1/accounts//followers", accounts.account_followers), path("v1/accounts//featured_tags", accounts.account_featured_tags), + path("v1/accounts//lists", accounts.account_lists), # Announcements path("v1/announcements", announcements.announcement_list), path("v1/announcements//dismiss", announcements.announcement_dismiss), @@ -67,7 +68,29 @@ path("v1/instance/peers", instance.peers), path("v2/instance", instance.instance_info_v2), # Lists - path("v1/lists", lists.get_lists), + path( + "v1/lists", + methods( + get=lists.get_lists, + post=lists.create_list, + ), + ), + path( + "v1/lists/", + methods( + get=lists.get_list, + put=lists.update_list, + delete=lists.delete_list, + ), + ), + path( + "v1/lists//accounts", + methods( + get=lists.get_accounts, + post=lists.add_accounts, + delete=lists.delete_accounts, + ), + ), # Markers path( "v1/markers", @@ -134,6 +157,7 @@ path("v1/timelines/home", timelines.home), path("v1/timelines/public", timelines.public), path("v1/timelines/tag/", timelines.hashtag), + path("v1/timelines/list/", timelines.list_timeline), path("v1/conversations", timelines.conversations), path("v1/favourites", timelines.favourites), # Trends diff --git a/api/views/accounts.py b/api/views/accounts.py index 629051ff9..44bb4b713 100644 --- a/api/views/accounts.py +++ b/api/views/accounts.py @@ -373,3 +373,15 @@ def account_followers( def account_featured_tags(request: HttpRequest, id: str) -> list[schemas.FeaturedTag]: # Not implemented yet return [] + + +@scope_required("read:lists") +@api_view.get +def account_lists(request: HttpRequest, id: str) -> list[schemas.List]: + identity = get_object_or_404( + Identity.objects.exclude(restriction=Identity.Restriction.blocked), pk=id + ) + return [ + schemas.List.from_list(lst) + for lst in request.identity.lists.filter(members=identity) + ] diff --git a/api/views/lists.py b/api/views/lists.py index 2ff10d2d6..b32e3d8f8 100644 --- a/api/views/lists.py +++ b/api/views/lists.py @@ -1,12 +1,95 @@ +from typing import Literal + from django.http import HttpRequest -from hatchway import api_view +from django.shortcuts import get_object_or_404 +from hatchway import Schema, api_view from api import schemas from api.decorators import scope_required +class CreateList(Schema): + title: str + replies_policy: Literal["followed", "list", "none"] = "list" + exclusive: bool = False + + +class UpdateList(Schema): + title: str | None + replies_policy: Literal["followed", "list", "none"] | None + exclusive: bool | None + + @scope_required("read:lists") @api_view.get def get_lists(request: HttpRequest) -> list[schemas.List]: - # We don't implement this yet - return [] + return [schemas.List.from_list(lst) for lst in request.identity.lists.all()] + + +@scope_required("write:lists") +@api_view.post +def create_list(request: HttpRequest, data: CreateList) -> schemas.List: + created = request.identity.lists.create( + title=data.title, + replies_policy=data.replies_policy, + exclusive=data.exclusive, + ) + return schemas.List.from_list(created) + + +@scope_required("read:lists") +@api_view.get +def get_list(request: HttpRequest, id: str) -> schemas.List: + alist = get_object_or_404(request.identity.lists, pk=id) + return schemas.List.from_list(alist) + + +@scope_required("write:lists") +@api_view.put +def update_list(request: HttpRequest, id: str, data: UpdateList) -> schemas.List: + alist = get_object_or_404(request.identity.lists, pk=id) + if data.title: + alist.title = data.title + if data.replies_policy: + alist.replies_policy = data.replies_policy + if data.exclusive is not None: + alist.exclusive = data.exclusive + alist.save() + return schemas.List.from_list(alist) + + +@scope_required("write:lists") +@api_view.delete +def delete_list(request: HttpRequest, id: str) -> dict: + alist = get_object_or_404(request.identity.lists, pk=id) + alist.delete() + return {} + + +@scope_required("write:lists") +@api_view.get +def get_accounts(request: HttpRequest, id: str) -> list[schemas.Account]: + alist = get_object_or_404(request.identity.lists, pk=id) + return [schemas.Account.from_identity(ident) for ident in alist.members.all()] + + +@scope_required("write:lists") +@api_view.post +def add_accounts(request: HttpRequest, id: str) -> dict: + alist = get_object_or_404(request.identity.lists, pk=id) + add_ids = request.PARAMS.get("account_ids") + for follow in request.identity.outbound_follows.filter( + target__id__in=add_ids + ).select_related("target"): + alist.members.add(follow.target) + return {} + + +@scope_required("write:lists") +@api_view.delete +def delete_accounts(request: HttpRequest, id: str) -> dict: + alist = get_object_or_404(request.identity.lists, pk=id) + remove_ids = request.PARAMS.get("account_ids") + for ident in alist.members.filter(id__in=remove_ids): + alist.members.remove(ident) + return {} diff --git a/api/views/timelines.py b/api/views/timelines.py index 9f4ed5afb..6e96d8588 100644 --- a/api/views/timelines.py +++ b/api/views/timelines.py @@ -1,4 +1,5 @@ from django.http import HttpRequest +from django.shortcuts import get_object_or_404 from hatchway import ApiError, ApiResponse, api_view from activities.models import Post, TimelineEvent @@ -159,3 +160,31 @@ def favourites( request=request, include_params=["limit"], ) + + +@scope_required("read:lists") +@api_view.get +def list_timeline( + request: HttpRequest, + list_id: str, + max_id: str | None = None, + since_id: str | None = None, + min_id: str | None = None, + limit: int = 20, +) -> ApiResponse[list[schemas.Status]]: + alist = get_object_or_404(request.identity.lists, pk=list_id) + queryset = TimelineService(request.identity).for_list(alist) + + paginator = MastodonPaginator() + pager: PaginationResult[Post] = paginator.paginate( + queryset, + min_id=min_id, + max_id=max_id, + since_id=since_id, + limit=limit, + ) + return PaginatingApiResponse( + schemas.Status.map_from_post(pager.results, request.identity), + request=request, + include_params=["limit"], + ) diff --git a/users/admin.py b/users/admin.py index 6ec0e279c..2cd5fee56 100644 --- a/users/admin.py +++ b/users/admin.py @@ -13,6 +13,7 @@ Identity, InboxMessage, Invite, + List, Marker, PasswordReset, Report, @@ -213,6 +214,12 @@ class InviteAdmin(admin.ModelAdmin): list_display = ["id", "created", "token", "note"] +@admin.register(List) +class ListAdmin(admin.ModelAdmin): + list_display = ["id", "identity", "title", "replies_policy", "exclusive"] + autocomplete_fields = ["members"] + + @admin.register(Marker) class MarkerAdmin(admin.ModelAdmin): list_display = ["id", "identity", "timeline", "last_read_id", "updated_at"] diff --git a/users/migrations/0024_list.py b/users/migrations/0024_list.py new file mode 100644 index 000000000..0a5e0cb15 --- /dev/null +++ b/users/migrations/0024_list.py @@ -0,0 +1,54 @@ +# Generated by Django 4.2.11 on 2024-04-19 01:03 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("users", "0023_marker"), + ] + + operations = [ + migrations.CreateModel( + name="List", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=200)), + ( + "replies_policy", + models.CharField( + choices=[ + ("followed", "Followed"), + ("list", "List Only"), + ("none", "None"), + ], + max_length=10, + ), + ), + ("exclusive", models.BooleanField()), + ( + "identity", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="lists", + to="users.identity", + ), + ), + ( + "members", + models.ManyToManyField( + blank=True, related_name="in_lists", to="users.identity" + ), + ), + ], + ), + ] diff --git a/users/models/__init__.py b/users/models/__init__.py index c0a91d026..40d1885d6 100644 --- a/users/models/__init__.py +++ b/users/models/__init__.py @@ -7,6 +7,7 @@ from .identity import Identity, IdentityStates # noqa from .inbox_message import InboxMessage, InboxMessageStates # noqa from .invite import Invite # noqa +from .lists import List # noqa from .marker import Marker # noqa from .password_reset import PasswordReset # noqa from .report import Report # noqa diff --git a/users/models/lists.py b/users/models/lists.py new file mode 100644 index 000000000..2daf25ee3 --- /dev/null +++ b/users/models/lists.py @@ -0,0 +1,37 @@ +from django.db import models + + +class List(models.Model): + """ + A list of accounts. + """ + + class RepliesPolicy(models.TextChoices): + followed = "followed" + list_only = "list" + none = "none" + + identity = models.ForeignKey( + "users.Identity", + on_delete=models.CASCADE, + related_name="lists", + ) + title = models.CharField(max_length=200) + replies_policy = models.CharField(max_length=10, choices=RepliesPolicy.choices) + exclusive = models.BooleanField() + members = models.ManyToManyField( + "users.Identity", + related_name="in_lists", + blank=True, + ) + + def __str__(self): + return f"#{self.id}: {self.identity} → {self.title}" + + def to_mastodon_json(self): + return { + "id": str(self.id), + "title": self.title, + "replies_policy": self.replies_policy, + "exclusive": self.exclusive, + } From 5035b39a6c09742e0f1b027a4de891d5f3f5b7ad Mon Sep 17 00:00:00 2001 From: Dan Watson Date: Fri, 19 Apr 2024 23:20:44 -0400 Subject: [PATCH 3/3] Docs --- docs/features.rst | 2 +- docs/releases/next.rst | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/features.rst b/docs/features.rst index b1fc9954f..2305a5878 100644 --- a/docs/features.rst +++ b/docs/features.rst @@ -30,7 +30,7 @@ Currently, it supports: * Signup flow, including auto-cap by user numbers and invite system * Password reset via email * Bookmarks -# Markers +* Markers Features planned for releases up to 1.0: diff --git a/docs/releases/next.rst b/docs/releases/next.rst index cae7bd41f..b57d036b7 100644 --- a/docs/releases/next.rst +++ b/docs/releases/next.rst @@ -20,3 +20,10 @@ Marker Support Takahē now supports the `Markers API `_, used by clients to sync read positions within timelines. + + +Lists Support +~~~~~~~~~~~~~ + +Takahē now supports the `Lists APIs `_, +used by clients to maintain lists of accounts to show timelines for.