diff --git a/src/core/admin.py b/src/core/admin.py
index 1494e08a694ed197a187173fc6569e4a7692f65a..bf17eb836cacce598e207f7beba249f72ac08a87 100644
--- a/src/core/admin.py
+++ b/src/core/admin.py
@@ -678,7 +678,7 @@ class EventAdmin(admin.ModelAdmin):
         ),
         (
             'Data',
-            {'fields': ['name', 'slug', 'language', 'abstract', 'description_de', 'description_en', 'banner_image', 'additional_data']},
+            {'fields': ['name', 'slug', 'language', 'abstract', 'description_de', 'description_en', 'banner_image', 'banner_image_url', 'additional_data']},
         ),
     )
 
diff --git a/src/core/locale/de/LC_MESSAGES/django.po b/src/core/locale/de/LC_MESSAGES/django.po
index b20ead0ddeaf3f61471cdadacbf872eeaf02ec94..688dfe079bea5801a2835f4d419f2b64f42aa3ba 100644
--- a/src/core/locale/de/LC_MESSAGES/django.po
+++ b/src/core/locale/de/LC_MESSAGES/django.po
@@ -994,6 +994,12 @@ msgstr "Symbolbild für die Veranstaltung welches als Banner angezeigt wird"
 msgid "Event__banner_image"
 msgstr "Banner"
 
+msgid "Event__banner_image_url__help"
+msgstr "URL welche beim Import des Banner Images verwendet wurde"
+
+msgid "Event__banner_image_url"
+msgstr "Banner Quell-URL"
+
 msgid "Event__is_public__help"
 msgstr "wird im Fahrplan öffentlich angezeigt"
 
@@ -2255,6 +2261,9 @@ msgstr "Telefon/Handy"
 msgid "UserCommunicationChannel__channel-matrix"
 msgstr "Matrix (Riot/Element)"
 
+msgid "UserCommunicationChannel__channel-activitypub"
+msgstr "ActivityPub (z. B. Mastodon)"
+
 msgid "UserCommunicationChannel__channel__help"
 msgstr "Art des Kommunikationskanals"
 
@@ -2267,6 +2276,12 @@ msgstr "Kontaktangabe in kanalspezifischer Notation"
 msgid "UserCommunicationChannel__address"
 msgstr "Ziel"
 
+msgid "UserCommunicationChannel__caption__help"
+msgstr "Freitext/Bezeichnung für den Kanal, wird ggf. öffentlich eingeblendet"
+
+msgid "UserCommunicationChannel__caption"
+msgstr "Beschriftung"
+
 msgid "UserCommunicationChannel__is_verified__help"
 msgstr "die Kontaktadresse wurde verifiziert"
 
diff --git a/src/core/locale/en/LC_MESSAGES/django.po b/src/core/locale/en/LC_MESSAGES/django.po
index 8e2d4907c0aa41bceaf58c354c7e1430635c4f95..ea7d8e254c5858b570ea1801da22fc34eaa61c0b 100644
--- a/src/core/locale/en/LC_MESSAGES/django.po
+++ b/src/core/locale/en/LC_MESSAGES/django.po
@@ -994,6 +994,12 @@ msgstr "representational image for this event, will be used as a banner"
 msgid "Event__banner_image"
 msgstr "banner"
 
+msgid "Event__banner_image_url__help"
+msgstr "URL of the image which was imported as banner"
+
+msgid "Event__banner_image_url"
+msgstr "banner source URL"
+
 msgid "Event__is_public__help"
 msgstr "shall the event be shown in the public timetable?"
 
@@ -2253,6 +2259,9 @@ msgstr "phone/mobile"
 msgid "UserCommunicationChannel__channel-matrix"
 msgstr "Matrix (Riot/Element)"
 
+msgid "UserCommunicationChannel__channel-activitypub"
+msgstr "ActivityPub (e.g. Mastodon)"
+
 msgid "UserCommunicationChannel__channel__help"
 msgstr "the type of communication channel"
 
@@ -2265,6 +2274,12 @@ msgstr "contact address in channel-specific notation"
 msgid "UserCommunicationChannel__address"
 msgstr "Address"
 
+msgid "UserCommunicationChannel__caption__help"
+msgstr "description of this communication channel, might be shown publicly"
+
+msgid "UserCommunicationChannel__caption"
+msgstr "caption"
+
 msgid "UserCommunicationChannel__is_verified__help"
 msgstr "this contact detail has been verified"
 
diff --git a/src/core/migrations/0159_event_banner_image_url_alter_event_banner_image.py b/src/core/migrations/0159_event_banner_image_url_alter_event_banner_image.py
new file mode 100644
index 0000000000000000000000000000000000000000..86121be48e3b7c4477a395a357a5ff80f889811a
--- /dev/null
+++ b/src/core/migrations/0159_event_banner_image_url_alter_event_banner_image.py
@@ -0,0 +1,25 @@
+# Generated by Django 5.1.2 on 2024-11-17 13:41
+
+import core.models.events
+import core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0158_invitation'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='event',
+            name='banner_image_url',
+            field=models.URLField(blank=True, help_text='Event__banner_image_url__help', null=True, verbose_name='Event__banner_image_url'),
+        ),
+        migrations.AlterField(
+            model_name='event',
+            name='banner_image',
+            field=models.ImageField(blank=True, height_field='banner_image_height', help_text='Event__banner_image__help', null=True, upload_to=core.models.events.get_event_banner_filename, validators=[core.validators.ImageDimensionValidator(max_size=(2048, 1024), min_size=(100, 100), square=False)], verbose_name='Event__banner_image', width_field='banner_image_width'),
+        ),
+    ]
diff --git a/src/core/migrations/0160_usercommunicationchannel_caption_and_more.py b/src/core/migrations/0160_usercommunicationchannel_caption_and_more.py
new file mode 100644
index 0000000000000000000000000000000000000000..98d0703d8b8549d8f1b4cc14df0258ab5ac41fff
--- /dev/null
+++ b/src/core/migrations/0160_usercommunicationchannel_caption_and_more.py
@@ -0,0 +1,23 @@
+# Generated by Django 5.1.2 on 2024-11-17 17:34
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0159_event_banner_image_url_alter_event_banner_image'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='usercommunicationchannel',
+            name='caption',
+            field=models.CharField(blank=True, help_text='UserCommunicationChannel__caption__help', max_length=200, null=True, verbose_name='UserCommunicationChannel__caption'),
+        ),
+        migrations.AlterField(
+            model_name='usercommunicationchannel',
+            name='channel',
+            field=models.CharField(choices=[('mail', 'UserCommunicationChannel__channel-mail'), ('xmpp', 'UserCommunicationChannel__channel-xmpp'), ('irc', 'UserCommunicationChannel__channel-irc'), ('dect', 'UserCommunicationChannel__channel-dect'), ('phone', 'UserCommunicationChannel__channel-phone'), ('matrix', 'UserCommunicationChannel__channel-matrix'), ('activitypub', 'UserCommunicationChannel__channel-activitypub')], help_text='UserCommunicationChannel__channel__help', max_length=20, verbose_name='UserCommunicationChannel__channel'),
+        ),
+    ]
diff --git a/src/core/models/assemblies.py b/src/core/models/assemblies.py
index eee23e2b6fbf96d7febbf1f57b919cb6eb702dec..5627235495a173fd6478caeb685d7fc533f43f89 100644
--- a/src/core/models/assemblies.py
+++ b/src/core/models/assemblies.py
@@ -37,17 +37,21 @@ from core.validators import FileSizeValidator, ImageDimensionValidator
 
 @rules.predicate
 def is_assembly_member(user: PlatformUser, assembly: 'Assembly') -> bool:
+    if assembly is None:
+        return False
     return assembly.has_user(user)
 
 
 @rules.predicate
 def is_assembly_manager(user: PlatformUser, assembly: 'Assembly') -> bool:
+    if assembly is None:
+        return False
     return assembly.user_can_manage(user)
 
 
 @rules.predicate
 def is_habitat_manager(user: PlatformUser, assembly: 'Assembly') -> bool:
-    if assembly.parent is None:
+    if assembly is None or assembly.parent is None:
         return False
     return assembly.parent.user_can_manage(user)
 
diff --git a/src/core/models/events.py b/src/core/models/events.py
index 36c12777f231b00f3ce52084ecea37bfb37da595..ff707ee50d40204a7992c0a116a3d6e8d118ea01 100644
--- a/src/core/models/events.py
+++ b/src/core/models/events.py
@@ -1,6 +1,7 @@
 import logging
 import random
 from datetime import timedelta
+from pathlib import Path
 from re import compile as re_compile
 from typing import Any
 from uuid import UUID, uuid4
@@ -30,13 +31,19 @@ from core.models.rooms import Room
 from core.models.shared import BackendMixin
 from core.models.tags import TaggedItemMixin, TagItem
 from core.models.users import PlatformUser
-from core.utils import str2bool, str2timedelta
+from core.utils import download_from_url, str2bool, str2timedelta
+from core.validators import ImageDimensionValidator
 
 SIMPLE_TIME_RE = re_compile(r'(.*\s)?(\d+:\d+)$')
 
 logger = logging.getLogger(__name__)
 
 
+def get_event_banner_filename(instance: 'Event', filename: Path | str):
+    ext = str(filename).rsplit('.', maxsplit=1)[-1]
+    return Path('events').joinpath(f'{instance.id}.{ext}')
+
+
 class EventDurationFormField(DurationField):
     def to_python(self, value):
         try:
@@ -132,11 +139,26 @@ class Event(TaggedItemMixin, BackendMixin, ActivityLogMixin, models.Model):
     banner_image = models.ImageField(
         blank=True,
         null=True,
+        upload_to=get_event_banner_filename,
         height_field='banner_image_height',
         width_field='banner_image_width',
         help_text=_('Event__banner_image__help'),
         verbose_name=_('Event__banner_image'),
+        validators=[
+            ImageDimensionValidator(
+                min_size=(
+                    settings.EVENT_BANNER_WIDTH_MINIMUM,
+                    settings.EVENT_BANNER_HEIGHT_MINIMUM,
+                ),
+                max_size=(
+                    settings.EVENT_BANNER_WIDTH_MAXIMUM,
+                    settings.EVENT_BANNER_HEIGHT_MAXIMUM,
+                ),
+                square=False,
+            ),
+        ],
     )
+    banner_image_url = models.URLField(blank=True, null=True, help_text=_('Event__banner_image_url__help'), verbose_name=_('Event__banner_image_url'))
 
     is_public = models.BooleanField(default=False, help_text=_('Event__is_public__help'), verbose_name=_('Event__is_public'))
     recording = models.CharField(
@@ -423,6 +445,15 @@ class Event(TaggedItemMixin, BackendMixin, ActivityLogMixin, models.Model):
         if obj.additional_data and obj.additional_data.get('do_not_record', False) is True:
             obj.recording = Event.Recording.NO
 
+        # load banner_image, if a URL is given (and has not changed since last time)
+        if banner_image_url := data.get('banner_image_url'):
+            if obj.banner_image_url != banner_image_url:
+                try:
+                    obj.load_banner_image_from_url(banner_image_url)
+                except Exception:
+                    # just log the error, if the event has no banner_image (yet), we don't care too much
+                    logger.exception('Failed to download banner_image for (new) event %s: %s', obj.pk, banner_image_url)
+
         obj.clean()
         return obj
 
@@ -626,6 +657,19 @@ class Event(TaggedItemMixin, BackendMixin, ActivityLogMixin, models.Model):
             self.pk,
         )
 
+    def load_banner_image_from_url(self, url: str, save: bool = True):
+        logger.debug('Loading banner_image for event %s (%s) from URL: %s', self.slug, self.pk, url)
+
+        filename, data = download_from_url(url)
+        target_filename = get_event_banner_filename(self, filename)
+
+        logger.debug('Storing banner_image for user %s (%s): %s', self.slug, self.pk, target_filename)
+        self.banner_image.save(target_filename, data)
+        self.banner_image_url = url
+        if save:
+            self.save(update_fields=['banner_image', 'banner_image_url'])
+        logger.debug('Updated banner_image for user %s (%s) as "%s" from URL: %s', self.slug, self.pk, target_filename, url)
+
 
 def _EventAttachment_upload_path(instance, filename):
     # file will be uploaded to MEDIA_ROOT/user_<id>/<filename>
diff --git a/src/core/models/users.py b/src/core/models/users.py
index 65d89795a317dd8919f34ef60688b26a7b162e09..717f9827668f360941c16dd742c1da1ae4b6b614 100644
--- a/src/core/models/users.py
+++ b/src/core/models/users.py
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any
 from uuid import uuid4
 
 from timezone_field import TimeZoneField
+from urllib3.util import parse_url
 
 from django.conf import settings
 from django.contrib.auth.models import AbstractUser, UserManager
@@ -418,7 +419,6 @@ class PlatformUser(AbstractUser):
             if obj.avatar_url != avatar_url:
                 try:
                     obj.load_avatar_from_url(avatar_url)
-                    obj.avatar_url = avatar_url
                 except Exception:
                     # just log the error, if the speaker has no avatar (yet), we don't care too much
                     logger.exception('Failed to download avatar for (new) speaker user %s: %s', obj.pk, avatar_url)
@@ -528,8 +528,9 @@ class PlatformUser(AbstractUser):
 
         logger.debug('Storing avatar for user %s (%s): %s', self.username, self.pk, target_filename)
         self.avatar.save(target_filename, data)
+        self.avatar_url = url
         if save:
-            self.save(update_fields=['avatar'])
+            self.save(update_fields=['avatar', 'avatar_url'])
         logger.debug('Updated avatar for user %s (%s) as "%s" from URL: %s', self.username, self.pk, target_filename, url)
 
 
@@ -576,6 +577,7 @@ class UserCommunicationChannel(models.Model):
         DECT = 'dect', _('UserCommunicationChannel__channel-dect')
         PHONE = 'phone', _('UserCommunicationChannel__channel-phone')
         MATRIX = 'matrix', _('UserCommunicationChannel__channel-matrix')
+        ACTIVITYPUB = 'activitypub', _('UserCommunicationChannel__channel-activitypub')
 
     user = models.ForeignKey(PlatformUser, related_name='communication_channels', on_delete=models.CASCADE)
 
@@ -584,6 +586,10 @@ class UserCommunicationChannel(models.Model):
     )
     address = models.CharField(max_length=255, help_text=_('UserCommunicationChannel__address__help'), verbose_name=_('UserCommunicationChannel__address'))
 
+    caption = models.CharField(
+        max_length=200, blank=True, null=True, help_text=_('UserCommunicationChannel__caption__help'), verbose_name=_('UserCommunicationChannel__caption')
+    )
+
     is_verified = models.BooleanField(
         default=False, help_text=_('UserCommunicationChannel__is_verified__help'), verbose_name=_('UserCommunicationChannel__is_verified')
     )
@@ -625,6 +631,14 @@ class UserCommunicationChannel(models.Model):
         if _RE_TELEPHONE.match(address) is None and _RE_VANITY.match(address) is None:
             raise ValidationError({'address': 'expected an international telephone number (starting with + or 00)'})
 
+    @staticmethod
+    def validate_activitypub_url(address):
+        url = parse_url(address)
+        if url.scheme not in ['http', 'https']:
+            raise ValidationError({'address': 'Expected https:// URL.'})
+        if url.host is None or url.path is None:
+            raise ValidationError({'address': 'Expected valid URL.'})
+
     @property
     def can_notify(self):
         """Signals whether this channel can be used for notifications."""
@@ -644,6 +658,9 @@ class UserCommunicationChannel(models.Model):
             if self.use_for_notifications:
                 raise ValidationError({'use_for_notifications': _('UserCommunicationChannel__cannot_notify__phone')})
 
+        elif self.channel == self.Channel.ACTIVITYPUB:
+            self.validate_activitypub_url(self.address)
+
         # TODO: verify the other channel types for correct syntax as well
 
         if self.use_for_notifications:
diff --git a/src/core/tests/users.py b/src/core/tests/users.py
index d1047ef2226ac09a739a0a308feaf5587c91e895..8f7d86b228aba0fc438f70386b91d574d5547b1c 100644
--- a/src/core/tests/users.py
+++ b/src/core/tests/users.py
@@ -72,6 +72,23 @@ class UserCommunicationChannelStaticTests(TestCase):
 
         self.check_good_bad('phone number', UserCommunicationChannel.validate_phone_number, good_numbers, bad_numbers)
 
+    def test_activitypub_validation(self):
+        good_addresses = [
+            'https://chaos.social/@ordnung',
+            'https://chaos.social/@events/113499399916580165',
+            'https://social.mrtoto.net/@apptest',
+            'https://social.mrtoto.net/@apptest/113085273939532302',
+        ]
+        bad_addresses = [
+            '',
+            'foo',
+            'chaos.social/ordnung',
+            'chaos.social/@ordnung',
+            'ftp://ccc.de',
+            'hub@cccv.de',
+        ]
+        self.check_good_bad('ActivityPub URL', UserCommunicationChannel.validate_activitypub_url, good_addresses, bad_addresses)
+
 
 class UserCommunicationChannelTests(TestCase):
     def setUp(self):
diff --git a/src/hub/settings/base.py b/src/hub/settings/base.py
index 234f57d16e5d2cf402ba2e5f4b67d362acb3ed6f..d32ac92f72b99957f2976cc8be3ce880d2e2cd7f 100644
--- a/src/hub/settings/base.py
+++ b/src/hub/settings/base.py
@@ -116,6 +116,10 @@ env = environ.FileAwareEnv(
     BADGE_IMAGE_WIDTH_MAXIMUM=(int, 512),
     BADGE_IMAGE_HEIGHT_MAXIMUM=(int, 512),
     INVITATION_REJECTION_DEFAULT_TIMEOUT=(int, 3),
+    EVENT_BANNER_WIDTH_MINIMUM=(int, 100),
+    EVENT_BANNER_HEIGHT_MINIMUM=(int, 100),
+    EVENT_BANNER_WIDTH_MAXIMUM=(int, 2048),
+    EVENT_BANNER_HEIGHT_MAXIMUM=(int, 1024),
     USER_AVATAR_WIDTH_MINIMUM=(int, 64),
     USER_AVATAR_HEIGHT_MINIMUM=(int, 64),
     USER_AVATAR_WIDTH_MAXIMUM=(int, 1024),
@@ -684,6 +688,11 @@ BADGE_IMAGE_HEIGHT_MINIMUM = env('BADGE_IMAGE_HEIGHT_MINIMUM')
 BADGE_IMAGE_WIDTH_MAXIMUM = env('BADGE_IMAGE_WIDTH_MAXIMUM')
 BADGE_IMAGE_HEIGHT_MAXIMUM = env('BADGE_IMAGE_HEIGHT_MAXIMUM')
 
+EVENT_BANNER_WIDTH_MINIMUM = env('EVENT_BANNER_WIDTH_MINIMUM')
+EVENT_BANNER_HEIGHT_MINIMUM = env('EVENT_BANNER_HEIGHT_MINIMUM')
+EVENT_BANNER_WIDTH_MAXIMUM = env('EVENT_BANNER_WIDTH_MAXIMUM')
+EVENT_BANNER_HEIGHT_MAXIMUM = env('EVENT_BANNER_HEIGHT_MAXIMUM')
+
 USER_AVATAR_WIDTH_MINIMUM = env('USER_AVATAR_WIDTH_MINIMUM')
 USER_AVATAR_HEIGHT_MINIMUM = env('USER_AVATAR_HEIGHT_MINIMUM')
 USER_AVATAR_WIDTH_MAXIMUM = env('USER_AVATAR_WIDTH_MAXIMUM')
diff --git a/src/plainui/jinja2/plainui/base.html.j2 b/src/plainui/jinja2/plainui/base.html.j2
index 4396c237f011b400c15825cdc3cc8144ff13c585..95ba02a0f5c000e553940d7cb857f3e54be5dc11 100644
--- a/src/plainui/jinja2/plainui/base.html.j2
+++ b/src/plainui/jinja2/plainui/base.html.j2
@@ -109,7 +109,7 @@
               <ul class="d-flex gap-2 list-unstyled">
                 <li>{{ hbtns.share() }}</li>
                 <!-- Temporary disabled due to 38c3 design implementation.
-                <li>{{ hbtns.themeswitcher() }}</li>
+                <li>{# hbtns.themeswitcher() #}</li>
                 -->
                 <li>{{ hbtns.globe() }}</li>
               </ul>
diff --git a/src/plainui/jinja2/plainui/components/list_events.html.j2 b/src/plainui/jinja2/plainui/components/list_events.html.j2
index f19e0724b0b0fcf30c24571739497d04a2916250..be7ff4bbb911152e611bafaba2fa82012eb52f17 100644
--- a/src/plainui/jinja2/plainui/components/list_events.html.j2
+++ b/src/plainui/jinja2/plainui/components/list_events.html.j2
@@ -73,10 +73,12 @@
           </div>
         {% endif %}
 
-        <div class="hub-tag__text">
-          <i class="bi bi-person-arms-up"></i>
-          {{ event.assembly.name }}
-        </div>
+        {% if not event.assembly.is_official %}
+          <div class="hub-tag__text">
+            <i class="bi bi-person-arms-up"></i>
+            {{ event.assembly.name }}
+          </div>
+        {% endif %}
       </div>
 
       <div class="hub-tags">
diff --git a/src/plainui/jinja2/plainui/event.html.j2 b/src/plainui/jinja2/plainui/event.html.j2
index 83de8c760abd00ac80888a6e057d0ca8330bd9b4..2730a046532c209522bcd0b880b56a57dbd1ad49 100644
--- a/src/plainui/jinja2/plainui/event.html.j2
+++ b/src/plainui/jinja2/plainui/event.html.j2
@@ -49,7 +49,8 @@
 
     {% set current_assembly = {
           "link": url('plainui:assembly', assembly_slug=assembly.slug),
-          "name": assembly.name
+          "name": assembly.name,
+          "is_official": assembly.is_official,
         } if assembly and assembly.slug else {} %}
 
     <div class="hub-vlayout">
@@ -157,7 +158,7 @@
           {% if event.description_html %}
             <div class="hub-text">{{ markdownMacro.markdown(markdown=event.description_html | safe, border=False) }}</div>
           {% endif %}
-          {% if current_assembly %}
+          {% if current_assembly and not current_assembly.is_official %}
             <div>
               <h2 class="hub-section-title">{{ _("Assembly") }}</h2>
               <div class="hub-text">
diff --git a/src/plainui/views/assemblies.py b/src/plainui/views/assemblies.py
index 67037006d0666742ceb68e1b7900b5db0fb9b8cc..c6a27a38df96cf4c3590b1087aa8a51cbbe94f7e 100644
--- a/src/plainui/views/assemblies.py
+++ b/src/plainui/views/assemblies.py
@@ -67,7 +67,7 @@ class AssemblyView(ConferenceRequiredMixin, DetailView):
             Prefetch(
                 'rooms',
                 to_attr='public_rooms',
-                queryset=Room.objects.conference_accessible(self.conf).order_by('name'),
+                queryset=Room.objects.conference_accessible(self.conf).order_by('official_room_order', 'name'),
             ),
             Prefetch(
                 'tags',