snapshot of https://github.com/zulip/zulip.git @ 48c69911c7dd4e03b20552eb3bd77ad8151c1357 (2026-09-15)
Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-15 15:05:30 +05:00
commit 64898b2c79
8999 changed files with 1757358 additions and 0 deletions

View File

@@ -0,0 +1,89 @@
[
{
"recipient_type": "channel",
"channel": "general",
"color": "#94C849",
"topic": "Zulip features",
"users": {
"Bo Williams": {},
"Ariella Drake": {},
"Kevin Lin": {},
"Nolan Turner": {},
"Anna Smith": {},
"Zoe Davis": {}
},
"messages": [
{
"sender": "Bo Williams",
"content": "When you hover over a Python code block, you'll now see a button that opens the code in a playground. Try it out: :playground_slide:\n\n```python\nprint(\"Hello world!\")\n```",
"reactions": {
"tada": [
"Zoe Davis",
"Nolan Turner",
"Anna Smith",
"Ariella Drake",
"Kevin Lin"
]
},
"date": {"year": 2024, "month": 5, "day": 16, "hour": 13, "minute": 59}
},
{
"sender": "Ariella Drake",
"content": "LaTeX math blocks are nice — I wish I had them in grad school. :wink:",
"date": {"year": 2024, "month": 5, "day": 16, "hour": 14, "minute": 27}
},
{
"sender": "Ariella Drake",
"content": "```math\n\\phi = 1 + \\cfrac{1}{1+\\cfrac{1}{1+\\cdots}}\n```",
"date": {"year": 2024, "month": 5, "day": 16, "hour": 14, "minute": 30}
},
{
"sender": "Kevin Lin",
"content": "Interesting! https://www.youtube.com/watch?v=cbj59mVwErg",
"date": {"year": 2024, "month": 5, "day": 16, "hour": 15, "minute": 5}
}
],
"screenshot": "static/images/landing-page/companies/message-formatting-top.png"
},
{
"recipient_type": "channel",
"channel": "general",
"color": "#9987E1",
"topic": "presentation feedback",
"users": {
"Maxy Stert": {},
"Kevin Lin": {}
},
"messages": [
{
"sender": "Maxy Stert",
"content": "Could someone take a look at my presentation draft? [presentation](#)",
"date": {"year": 2024, "month": 5, "day": 17, "hour": 16, "minute": 35}
},
{
"sender": "Kevin Lin",
"content": "Sure! Let’s do a call at <time:2024-05-17T14:30:00-07:00> to go through it.",
"reactions": {
"+1": ["Maxy Stert"]
},
"date": {"year": 2024, "month": 5, "day": 17, "hour": 17, "minute": 37}
},
{
"sender": "Maxy Stert",
"content": "[Join video call.](#)",
"date": {"year": 2024, "month": 5, "day": 17, "hour": 21, "minute": 30}
},
{
"sender": "Maxy Stert",
"content": "I got great feedback from @_**Kevin Lin**",
"date": {"year": 2024, "month": 5, "day": 17, "hour": 22, "minute": 7}
},
{
"sender": "Maxy Stert",
"content": "/poll When we should do a dry run?\nExtend tomorrow's meeting\nNext week",
"date": {"year": 2024, "month": 5, "day": 17, "hour": 22, "minute": 15}
}
],
"screenshot": "static/images/landing-page/companies/message-formatting-bottom.png"
}
]

View File

@@ -0,0 +1,136 @@
[
{
"recipient_type": "channel",
"channel": "Lecture 3 Heaps",
"color": "#A6DCBF",
"topic": "✔ problem 2a clarification",
"users": {
"John Lin": {},
"Dal Kim": {}
},
"messages": [
{
"sender": "John Lin",
"content": "I'm confused -- why does the code for this one say: \n\n```python\nint my_array[] = {0, 1, 2, 3}\n```\nBut in 2b it's:\n\n```python\nint my_array[4] = {0, 1, 2, 3}\n```\nis it a typo?",
"edited": true,
"date": {"year": 2025, "month": 6, "day": 19, "hour": 18, "minute": 0}
},
{
"sender": "Dal Kim",
"content": "Nope, both are correct! Take a look at slide 17 in the lecture notes.",
"reactions": {
"+1": ["John Lin"]
},
"date": {"year": 2025, "month": 6, "day": 19, "hour": 18, "minute": 37}
},
{
"sender": "Notification Bot",
"content": "@_**Dal Kim** has marked this topic as resolved.",
"date": {"year": 2025, "month": 6, "day": 19, "hour": 19, "minute": 0}
}
],
"screenshot": "static/images/landing-page/education/message_formatting_day_1.png"
},
{
"recipient_type": "channel",
"channel": "Lecture 3 Heaps",
"color": "#A6DCBF",
"topic": "lecture notes and recording",
"users": {
"Elena García": {},
"Dal Kim": {},
"John Lin": {},
"Maxy Stert": {},
"Kevin Lin": {}
},
"messages": [
{
"sender": "Elena García",
"content": "Here are the notes for today's class: [lecture3.pdf](#). You can also [watch the recording](#) if you missed it.",
"reactions": {
"+1": ["Dal Kim", "John Lin", "Maxy Stert", "Kevin Lin"]
},
"starred": true,
"date": {"year": 2025, "month": 5, "day": 21, "hour": 11, "minute": 3}
}
],
"screenshot": "static/images/landing-page/education/interactive_messaging_day_1.png"
},
{
"recipient_type": "channel",
"channel": "staff",
"invite_only": true,
"color": "#E4523D",
"topic": "assignment #3 solutions",
"users": {
"Dal Kim": {},
"Zoe Davis": {}
},
"messages": [
{
"sender": "Dal Kim",
"content": "Can someone double-check my solution for problem 4? The run time is $$O(\\log n)$$, right?",
"date": {"year": 2025, "month": 5, "day": 19, "hour": 14, "minute": 40}
},
{
"sender": "Zoe Davis",
"content": "Yep, that's the amortized time, but the upper bound of it is $$O(2^{2\\sqrt{\\log \\log n}})$$.",
"reactions": {
"+1": ["Iago"]
},
"date": {"year": 2025, "month": 5, "day": 19, "hour": 15, "minute": 43}
}
],
"screenshot": "static/images/landing-page/education/message_formatting_day_2.png"
},
{
"recipient_type": "channel",
"channel": "announcements",
"color": "#A6C7E5",
"topic": "homework reminders",
"users": {
"Elena García": {}
},
"messages": [
{
"sender": "Elena García",
"content": "@**everyone** Please remember to submit your assignments before <time:2025-05-12T00:00:00+05:30>! You will lose **at least 25%** of the credit if your assignment is late.",
"date": {"year": 2025, "month": 5, "day": 21, "hour": 13, "minute": 10}
},
{
"sender": "Elena García",
"content": "It sounds like many of you are stuck at the bonus problem, so here's a hint. Please think about the question first before you view it\n\n```spoiler Hint for bonus problem\nHint\n```",
"date": {"year": 2025, "month": 5, "day": 21, "hour": 14, "minute": 11}
}
],
"screenshot": "static/images/landing-page/education/interactive_messaging_day_2.png"
},
{
"recipient_type": "channel",
"channel": "announcements",
"color": "#A6C7E5",
"topic": "rescheduling office hours",
"user_groups": [
{
"group_name": "section 1",
"members": ["Dal Kim"]
}
],
"users": {
"Dal Kim": {}
},
"messages": [
{
"sender": "Dal Kim",
"content": "@*section 1* I need to reschedule my office hours this week. Which time works better?",
"date": {"year": 2025, "month": 5, "day": 20, "hour": 15, "minute": 15}
},
{
"sender": "Dal Kim",
"content": "/poll Section 1 office hours\nThursday 1-2 PM\nFriday 10-11 AM",
"date": {"year": 2025, "month": 5, "day": 20, "hour": 15, "minute": 20}
}
],
"screenshot": "static/images/landing-page/education/interactive_messaging_day_3.png"
}
]

View File

@@ -0,0 +1,84 @@
[
{
"recipient_type": "channel",
"channel": "Session 2 Keynote",
"color": "#C6A8AD",
"topic": "slides and presentation",
"users": {
"Dal Kim": {},
"Kevin Lin": {},
"John Lin": {}
},
"messages": [
{
"sender": "Dal Kim",
"content": "Welcome everyone! Here are the slides for the keynote talk: [keynote-slides.ppt](#). Don't forget to join for a live presentation at <time:2024-05-22T17:00:00+05:30>!",
"starred": true,
"reactions": {
"+1": ["Iago", "Kevin Lin", "John Lin"],
"tada": ["John Lin", "Kevin Lin"]
},
"date": {"year": 2024, "month": 5, "day": 21, "hour": 13, "minute": 45}
},
{
"sender": "Dal Kim",
"content": "Join now for the Keynote presentation!\nWe'll be using this stream for Q&A.\n\n[Join video call.](#)",
"date": {"year": 2024, "month": 5, "day": 21, "hour": 17, "minute": 0}
}
],
"screenshot": "static/images/landing-page/events/your_communication_hub_day_1.png"
},
{
"recipient_type": "channel",
"channel": "general",
"color": "#9987E1",
"topic": "conference feedback",
"users": {
"James Williams": {}
},
"messages": [
{
"sender": "James Williams",
"content": "As we are wrapping up, let us know what you enjoyed the most about this conference.",
"date": {"year": 2024, "month": 5, "day": 21, "hour": 20, "minute": 50}
},
{
"sender": "James Williams",
"content": "/poll What did you like the best?\nKeynote address\nLightning talks\nUsing Zulip",
"date": {"year": 2024, "month": 5, "day": 21, "hour": 20, "minute": 55}
}
],
"screenshot": "static/images/landing-page/events/your_communication_hub_day_2.png"
},
{
"recipient_type": "channel",
"channel": "Day 2: Intro to RSA",
"color": "#94C849",
"topic": "factorization algorithms",
"users": {
"Manvir Singh": {},
"Zoe Davis": {},
"Maxy Stert": {}
},
"messages": [
{
"sender": "Manvir Singh",
"content": "You could use [Pollard's rho algorithm](#), which factors a number n in time $$O(n^{\\frac{1}{4}})$$, faster than trial division:\n\n```python\ndef pollard_rho(n: int) -> int:\n g = lambda x: (x ** 2 + 1) % n\n x, y, d = 2, 2, 1\n while d == 1:\n x, y = g(x), g(g(y))\n d = math.gcd(x - y, n)\n return d\n```",
"reactions": {
"+1": ["Zoe Davis"]
},
"date": {"year": 2024, "month": 5, "day": 14, "hour": 16, "minute": 59}
},
{
"sender": "Zoe Davis",
"content": "The fastest known algorithm is the general number field sieve, which heuristically runs in time:\n```math\n\\exp\\left(\\left(\\sqrt[3]{\\frac{64}{9}} + o(1)\\right)(\\ln n)^{\\frac{1}{3}} (\\ln\\ln n)^{\\frac{2}{3}}\\right)\n```\n However, it is much more complicated.",
"reactions": {
"runner": ["Manvir Singh"],
"thinking": ["Maxy Stert", "Iago"]
},
"date": {"year": 2024, "month": 5, "day": 14, "hour": 18, "minute": 19}
}
],
"screenshot": "static/images/landing-page/events/message_formatting_day.png"
}
]

View File

@@ -0,0 +1,67 @@
[
{
"recipient_type": "channel",
"channel": "general",
"color": "#9987E1",
"topic": "paper posted",
"users": {
"John Lin": {},
"Dal Kim": {},
"Bo Lin": {}
},
"messages": [
{
"sender": "John Lin",
"content": "Hi all! We just posted a pre-publication draft of our paper! Sharing here for feedback, suggestions, etc. [paper.pdf](#)",
"reactions": {
"tada": ["John Lin", "Iago", "Dal Kim", "Bo Lin"],
"eyes": ["Dal Kim", "Iago"]
},
"date": {"year": 2024, "month": 6, "day": 4, "hour": 14, "minute": 39}
},
{
"sender": "Dal Kim",
"content": "Just noticed a small typo in Figure 4. The caption says `detales` instead of `details`. :smile:",
"reactions": {
"check": ["John Lin"]
},
"date": {"year": 2024, "month": 6, "day": 4, "hour": 14, "minute": 57}
}
],
"screenshot": "static/images/landing-page/research/interactive_messaging_day_1.png"
},
{
"recipient_type": "channel",
"channel": "thesis support group",
"color": "#95A5FD",
"topic": "chat live",
"users": {
"Zoe Davis": {},
"James Williams": {},
"Bo Lin": {},
"Nolan Turner": {}
},
"messages": [
{
"sender": "Zoe Davis",
"content": "Hey! How does <time:2024-05-14T17:00:00+05:30> sound for doing a video call today? My paper just got rejected.",
"reactions": {
"+1": ["James Williams", "Iago", "Zoe Davis", "Bo Lin", "Nolan Turner"],
"broken_heart": ["James Williams", "Iago", "Zoe Davis", "Nolan Turner"]
},
"date": {"year": 2024, "month": 5, "day": 13, "hour": 15, "minute": 15}
},
{
"sender": "James Williams",
"content": "I need to send off a draft of chapter 2 to my advisor. Will try to make it!",
"date": {"year": 2024, "month": 5, "day": 13, "hour": 15, "minute": 34}
},
{
"sender": "Zoe Davis",
"content": "@**everyone** Video call is happening now! [Join video call.](#)",
"date": {"year": 2024, "month": 5, "day": 13, "hour": 16, "minute": 30}
}
],
"screenshot": "static/images/landing-page/research/interactive_messaging_day_2.png"
}
]

View File

@@ -0,0 +1,513 @@
#!/usr/bin/env python3
"""Create or update a webhook integration screenshot using a test fixture."""
import argparse
import base64
import os
import re
import subprocess
import sys
from typing import Any
from urllib.parse import parse_qsl, urlencode
import zulip
SCREENSHOTS_DIR = os.path.abspath(os.path.dirname(__file__))
TOOLS_DIR = os.path.abspath(os.path.dirname(SCREENSHOTS_DIR))
ROOT_DIR = os.path.dirname(TOOLS_DIR)
sys.path.insert(0, ROOT_DIR)
# check for the venv
from tools.lib import sanity_check
sanity_check.check_venv(__file__)
from scripts.lib.setup_path import setup_path
setup_path()
os.environ["DJANGO_SETTINGS_MODULE"] = "zproject.settings"
import django
django.setup()
import orjson
import requests
from scripts.lib.zulip_tools import BOLDRED, ENDC
from tools.lib.test_script import prepare_puppeteer_run
from zerver.actions.create_user import do_create_user, notify_created_bot
from zerver.actions.message_send import do_send_messages, internal_prep_stream_message
from zerver.actions.streams import bulk_add_subscriptions
from zerver.actions.user_settings import do_change_avatar_fields
from zerver.lib.integrations import (
FIXTURELESS_INTEGRATIONS_WITH_SCREENSHOTS,
INCOMING_WEBHOOK_INTEGRATIONS,
INTEGRATIONS,
BotIntegration,
FixturelessScreenshotConfig,
IncomingWebhookIntegration,
Integration,
WebhookScreenshotConfig,
get_fixture_path,
get_image_path,
split_fixture_path,
)
from zerver.lib.storage import static_path
from zerver.lib.streams import create_stream_if_needed, ensure_stream
from zerver.lib.upload import upload_avatar_image
from zerver.lib.url_encoding import topic_narrow_url
from zerver.lib.webhooks.common import call_fixture_to_headers, parse_multipart_string
from zerver.models import Message, Stream, UserProfile
from zerver.models.realms import Realm, get_realm
from zerver.models.users import get_user_by_delivery_email
from zerver.webhooks.fixtureless_integrations import MessageThread
def create_integration_bot(integration: Integration, bot_name: str | None = None) -> UserProfile:
realm = get_realm("zulip")
owner = get_user_by_delivery_email("iago@zulip.com", realm)
email_prefix = (
re.sub(r"[^a-zA-Z0-9_-]", "", bot_name.strip()).lower()[:64]
if bot_name
else integration.name
)
bot_email = f"{email_prefix}-bot@example.com"
if bot_name is None:
if isinstance(integration, BotIntegration):
bot_name = integration.display_name
else:
bot_name = f"{integration.display_name} Bot"
try:
bot = UserProfile.objects.get(email=bot_email)
except UserProfile.DoesNotExist:
bot = do_create_user(
email=bot_email,
password="123",
realm=owner.realm,
full_name=bot_name,
bot_type=UserProfile.INCOMING_WEBHOOK_BOT,
bot_owner=owner,
acting_user=owner,
)
notify_created_bot(bot)
bot_avatar_path = integration.get_bot_avatar_path()
bot_avatar_path = static_path(bot_avatar_path)
if os.path.isfile(bot_avatar_path):
with open(bot_avatar_path, "rb") as f:
upload_avatar_image(f, bot, content_type="image/png")
do_change_avatar_fields(bot, UserProfile.AVATAR_FROM_USER, acting_user=owner)
else:
raise ValueError(f"Bot avatar file {bot_avatar_path} does not exist.")
return bot
def create_integration_channel(channel_name: str, bot: UserProfile) -> None:
assert isinstance(bot.bot_owner, UserProfile)
realm = bot.bot_owner.realm
channel, _created = create_stream_if_needed(realm, channel_name)
bulk_add_subscriptions(realm, [channel], [bot, bot.bot_owner], acting_user=bot)
def create_user(
realm: Realm, username: str, acting_user: UserProfile, channel: Stream
) -> UserProfile:
try:
user = UserProfile.objects.get(realm=realm, full_name=username)
except UserProfile.DoesNotExist:
sender_email = f"{username.replace(' ', '').lower()}@example.com"
user = do_create_user(
email=sender_email,
password="123",
realm=realm,
full_name=username,
acting_user=acting_user,
)
bulk_add_subscriptions(realm, [channel], [user], acting_user=acting_user)
return user
def get_fixture_info(fixture_path: str) -> tuple[Any, bool, bool, str]:
json_fixture = fixture_path.endswith(".json")
multipart_fixture = fixture_path.endswith(".multipart")
_, fixture_name = split_fixture_path(fixture_path)
if fixture_name:
if not os.path.exists(fixture_path):
raise FileNotFoundError(
f"{BOLDRED}The fixture '{fixture_path}' does not exist. Please check the fixture file name and try again.{ENDC}"
)
if json_fixture:
with open(fixture_path, "rb") as fb:
data = orjson.loads(fb.read())
else:
with open(fixture_path) as f:
data = f.read().strip()
if multipart_fixture:
data = parse_multipart_string(data)
else:
data = ""
return data, json_fixture, multipart_fixture, fixture_name
def get_requests_headers(integration_dir_name: str, fixture_name: str) -> dict[str, Any]:
headers = call_fixture_to_headers(integration_dir_name, fixture_name)
def fix_name(header: str) -> str:
return header.removeprefix("HTTP_").replace("_", "-")
return {fix_name(k): v for k, v in headers.items()}
def custom_headers(headers_json: str) -> dict[str, str]:
if not headers_json:
return {}
try:
return orjson.loads(headers_json)
except orjson.JSONDecodeError as ve:
raise argparse.ArgumentTypeError(
f"Encountered an error while attempting to parse custom headers: {ve}\n"
"Note: all strings must be enclosed within \"\" instead of ''"
)
def send_bot_payload_message(
bot: UserProfile,
integration: IncomingWebhookIntegration,
fixture_path: str,
config: WebhookScreenshotConfig,
) -> bool:
# Delete all messages, so new message is the only one it's message group
Message.objects.filter(realm_id=bot.realm_id, sender=bot).delete()
data, json_fixture, multipart_fixture, fixture_name = get_fixture_info(fixture_path)
headers = get_requests_headers(integration.dir_name, fixture_name)
if config.custom_headers:
headers.update(config.custom_headers)
if config.use_basic_auth:
credentials = base64.b64encode(f"{bot.email}:{bot.api_key}".encode()).decode()
auth = f"basic {credentials}"
headers.update(dict(Authorization=auth))
assert isinstance(bot.bot_owner, UserProfile)
channel_name = config.channel or integration.name
url = f"{bot.bot_owner.realm.url}/{integration.url}"
params = {"api_key": bot.api_key, "stream": channel_name}
params.update(config.extra_params)
extra_args = {}
if multipart_fixture:
extra_args = {"data": data}
elif not json_fixture and data:
assert isinstance(data, str)
# fixtures with url parameters
if "&" in data:
# Overwrite the fixture params, in case of overlap.
parsed_params = dict(parse_qsl(data))
parsed_params.update(params)
params = parsed_params
# fixtures with plain/text payload
else:
extra_args = {"data": data}
elif config.payload_as_query_param:
params[config.payload_param_name] = orjson.dumps(data).decode()
else:
extra_args = {"json": data}
url = f"{url}?{urlencode(params)}"
try:
response = requests.post(url=url, headers=headers, **extra_args)
except requests.exceptions.ConnectionError:
print(
"This tool needs the local dev server to be running. "
"Please start it using tools/run-dev before running this tool."
)
sys.exit(1)
if response.status_code != 200:
print(response.json())
print("Failed to trigger webhook")
return False
else:
print(f"Triggered {integration.name} webhook")
return True
def send_bot_mock_message(bot: UserProfile, channel: str, topic: str, message: str) -> None:
# Delete all messages, so that the new message is the only one in its message group
Message.objects.filter(realm_id=bot.realm_id, sender=bot).delete()
assert bot.bot_owner is not None
url = f"{bot.bot_owner.realm.url}"
client = zulip.Client(email=bot.email, api_key=bot.api_key, site=url)
request = {"type": "stream", "to": channel, "topic": topic, "content": message}
client.send_message(request)
def capture_last_message_screenshot(bot: UserProfile, image_path: str) -> None:
message = Message.objects.filter(realm_id=bot.realm_id, sender=bot).last()
realm = get_realm("zulip")
if message is None:
print(f"No message found for {bot.full_name}")
return
message_id = str(message.id)
screenshot_script = os.path.join(SCREENSHOTS_DIR, "message-screenshot.ts")
subprocess.check_call(["node", screenshot_script, message_id, image_path, realm.url])
def send_thread_messages(
bot: UserProfile,
channel_name: str,
topic: str,
thread_messages_data: list[MessageThread],
integration: Integration,
) -> list[int]:
realm = bot.realm
channel = ensure_stream(realm, channel_name, acting_user=bot.bot_owner)
assert bot.bot_owner is not None
user = create_user(
realm=realm, username="Bo Williams", acting_user=bot.bot_owner, channel=channel
)
Message.objects.filter(
realm=realm,
recipient=channel.recipient,
subject__iexact=topic,
).delete()
message_requests = []
for messages in thread_messages_data:
sender_name = messages["sender"]
content = messages["message"]
if sender_name in {bot.full_name, integration.display_name, integration.name.title()}:
sender = bot
else:
sender = user
message_request = internal_prep_stream_message(
sender,
channel,
topic,
content,
)
message_requests.append(message_request)
sent_message_results = do_send_messages(message_requests)
return [result.message_id for result in sent_message_results]
def capture_thread_screenshot(
bot: UserProfile, channel_name: str, topic: str, message_ids: list[int], image_path: str
) -> None:
assert bot.bot_owner is not None
stream = ensure_stream(bot.realm, channel_name, acting_user=bot.bot_owner)
narrow_uri = topic_narrow_url(realm=bot.realm, stream=stream, topic_name=topic)
narrow = f"{channel_name}/{topic}"
screenshot_script = os.path.join(SCREENSHOTS_DIR, "thread-screenshot.ts")
subprocess.check_call(
[
"node",
screenshot_script,
narrow_uri,
narrow,
str(min(message_ids)),
image_path,
bot.realm.url,
]
)
def generate_screenshots_for_integration(integration: Integration) -> None:
if integration.screenshot_configs is not None:
for screenshot_config in integration.screenshot_configs:
generate_screenshot_from_config(integration.name, screenshot_config)
def generate_screenshot_from_config(
integration_name: str, screenshot_config: WebhookScreenshotConfig | FixturelessScreenshotConfig
) -> None:
integration = INTEGRATIONS[integration_name]
bot = create_integration_bot(integration, screenshot_config.bot_name)
channel_name = screenshot_config.channel or integration.name
create_integration_channel(channel_name, bot)
image_path = get_image_path(integration, screenshot_config)
match (integration, screenshot_config):
case (IncomingWebhookIntegration(), WebhookScreenshotConfig()):
fixture_path = get_fixture_path(integration, screenshot_config)
if send_bot_payload_message(bot, integration, fixture_path, screenshot_config):
capture_last_message_screenshot(bot, image_path)
case (_, FixturelessScreenshotConfig(message=messages)) if isinstance(messages, list):
message_ids = send_thread_messages(
bot, channel_name, screenshot_config.topic, messages, integration
)
if message_ids:
capture_thread_screenshot(
bot, channel_name, screenshot_config.topic, message_ids, image_path
)
case (_, FixturelessScreenshotConfig(message=message)) if isinstance(message, str):
send_bot_mock_message(
bot,
channel=channel_name,
topic=screenshot_config.topic,
message=message,
)
capture_last_message_screenshot(bot, image_path)
case _:
raise ValueError(f"Invalid configuration for integration {integration.name}")
if os.path.exists(image_path):
print(f"Screenshot captured to: {BOLDRED}{image_path}{ENDC}")
parser = argparse.ArgumentParser()
batch_group = parser.add_mutually_exclusive_group(required=True)
batch_group.add_argument("--all", action="store_true")
batch_group.add_argument("--all-webhook", action="store_true")
batch_group.add_argument("--all-fixtureless", action="store_true")
batch_group.add_argument(
"--skip-until",
help="Name of the integration to start processing from, including all that follow in alphabetical order. Similar to --all",
)
batch_group.add_argument("--integration", nargs="+", help="Names of specific integrations")
common_group = parser.add_argument_group()
common_group.add_argument("--image-name", help="Name for the screenshot image")
common_group.add_argument("--image-dir", help="Directory name where to save the screenshot image")
common_group.add_argument("--bot-name", help="Name to use for the bot")
webhook_group = parser.add_argument_group("Webhook integrations options")
webhook_group.add_argument(
"--fixture-name",
help="Name of the fixture file to use. All other webhook options are ignored if the fixture is not specified.",
)
webhook_group.add_argument(
"-A", "--use-basic-auth", action="store_true", help="Add basic auth headers to the request"
)
webhook_group.add_argument(
"-Q",
"--payload-as-query-param",
action="store_true",
help="Send payload as query param instead of body",
)
webhook_group.add_argument("-P", "--payload-param-name", help="Param name to use for the payload")
webhook_group.add_argument(
"-H",
"--custom-headers",
type=custom_headers,
help="Any additional headers to be sent with the request.",
)
fixtureless_group = parser.add_argument_group("Fixtureless non-webhook integrations options")
fixtureless_group.add_argument("-T", "--topic", help="Topic to use for the mock message")
fixtureless_group.add_argument("-M", "--message", help="Message to use for the mock message")
fixtureless_group.add_argument(
"-C",
"--channel",
help="Channel name to use for the mock message. Ignored if --topic and --message are not specified.",
)
options = parser.parse_args()
prepare_puppeteer_run()
def validate_integration_count(
options: argparse.Namespace, parser: argparse.ArgumentParser, option_name: str
) -> None:
if len(options.integration) != 1:
parser.error(
f"Exactly one integration should be specified with --integration when --{option_name} is provided"
)
def build_config(options: argparse.Namespace, config_keys: list[str]) -> dict[str, Any]:
config = {
key: getattr(options, key) for key in config_keys if getattr(options, key) is not None
}
return config
def generate_full_batch_screenshots(
option_key: str,
integration_list: list[Integration] | list[IncomingWebhookIntegration],
batch_type: str,
) -> None:
for key, value in vars(options).items():
if key != option_key and value:
print(f"Generating screenshots for {batch_type}. Ignoring all command-line options")
break
for integration in integration_list:
generate_screenshots_for_integration(integration)
if options.all:
generate_full_batch_screenshots("all", list(INTEGRATIONS.values()), "all integrations")
elif options.all_webhook:
generate_full_batch_screenshots(
"all_webhook", INCOMING_WEBHOOK_INTEGRATIONS, "all incoming webhook integrations"
)
elif options.all_fixtureless:
fixtureless_integrations_list = [
INTEGRATIONS[name] for name in FIXTURELESS_INTEGRATIONS_WITH_SCREENSHOTS
]
generate_full_batch_screenshots(
"all_fixtureless", fixtureless_integrations_list, "all fixtureless integrations"
)
elif options.skip_until:
for key, value in vars(options).items():
if key != "skip_until" and value:
print(
f"Generating screenshots for all integrations starting from {options.skip_until}. Ignoring all command-line options"
)
break
skip = True
for integration_name in sorted(INTEGRATIONS):
if integration_name == options.skip_until:
skip = False
if skip:
continue
generate_screenshots_for_integration(INTEGRATIONS[integration_name])
elif options.fixture_name:
validate_integration_count(options, parser, "fixture-name")
webhook_options = [action.dest for action in webhook_group._group_actions]
common_options = [action.dest for action in common_group._group_actions]
config = build_config(options, webhook_options + common_options)
webhook_screenshot_config = WebhookScreenshotConfig(**config)
generate_screenshot_from_config(options.integration[0], webhook_screenshot_config)
elif options.topic or options.message:
if not options.topic or not options.message:
parser.error("Both --topic and --message must be specified together")
validate_integration_count(options, parser, "topic")
fixtureless_options = [action.dest for action in fixtureless_group._group_actions]
common_options = [action.dest for action in common_group._group_actions]
config = build_config(options, fixtureless_options + common_options)
fixtureless_screenshot_config = FixturelessScreenshotConfig(**config)
generate_screenshot_from_config(options.integration[0], fixtureless_screenshot_config)
elif options.integration:
for integration in options.integration:
assert integration in INTEGRATIONS
configs = INTEGRATIONS[integration].screenshot_configs
if configs is None:
parser.error(f"Could not find screenshot configuration for integration {integration}. ")
for screenshot_config in configs:
generate_screenshot_from_config(integration, screenshot_config)
else:
parser.error(
"Could not find configuration for integration. "
"You can specify a fixture file to use, using the --fixture flag. "
"Or add a configuration to the corresponding integration in zerver.lib.integrations.INTEGRATIONS.",
)

View File

@@ -0,0 +1,295 @@
#!/usr/bin/env python3
"""Create or update a message thread screenshot using a thread file."""
import argparse
import os
import subprocess
import sys
from datetime import datetime, timezone
from django.conf import settings
from pydantic import BaseModel, ConfigDict
SCREENSHOTS_DIR = os.path.abspath(os.path.dirname(__file__))
TOOLS_DIR = os.path.abspath(os.path.dirname(SCREENSHOTS_DIR))
ROOT_DIR = os.path.dirname(TOOLS_DIR)
sys.path.insert(0, ROOT_DIR)
# check for the venv
from tools.lib import sanity_check
sanity_check.check_venv(__file__)
from scripts.lib.setup_path import setup_path
setup_path()
os.environ["DJANGO_SETTINGS_MODULE"] = "zproject.settings"
import django
django.setup()
import json
from tools.lib.test_script import prepare_puppeteer_run
from zerver.actions.create_user import do_create_user
from zerver.actions.message_edit import check_update_message
from zerver.actions.message_flags import do_update_message_flags
from zerver.actions.message_send import do_send_messages, internal_prep_stream_message
from zerver.actions.reactions import do_add_reaction
from zerver.actions.streams import bulk_add_subscriptions, do_change_subscription_property
from zerver.actions.user_groups import check_add_user_group
from zerver.actions.user_settings import do_change_avatar_fields
from zerver.lib.emoji import get_emoji_data
from zerver.lib.message import SendMessageRequest, access_message
from zerver.lib.mime_types import guess_type
from zerver.lib.stream_subscription import get_active_subscriptions_for_stream_id
from zerver.lib.streams import access_stream_by_id, ensure_stream
from zerver.lib.timestamp import datetime_to_timestamp
from zerver.lib.upload import upload_avatar_image
from zerver.lib.url_encoding import topic_narrow_url
from zerver.models import Message, UserProfile
from zerver.models.groups import NamedUserGroup
from zerver.models.realms import get_realm
from zerver.models.users import get_system_bot, get_user_by_delivery_email
realm = get_realm("zulip")
realm.message_content_edit_limit_seconds = None
realm.save()
DEFAULT_USER = get_user_by_delivery_email("iago@zulip.com", realm)
NOTIFICATION_BOT = get_system_bot(settings.NOTIFICATION_BOT, realm.id)
message_thread_ids: list[int] = []
USER_AVATARS_MAP = {
"Ariella Drake": "tools/screenshots/user_avatars/AriellaDrake.png",
"Elena García": "tools/screenshots/user_avatars/ElenaGarcia.jpg",
"Kevin Lin": "tools/screenshots/user_avatars/KevinLin.png",
"Zoe Davis": "tools/screenshots/user_avatars/ZoeDavis.png",
"Bo Williams": "tools/screenshots/user_avatars/BoWilliams.png",
"James Williams": "tools/screenshots/user_avatars/JamesWilliams.png",
"Manvir Singh": "tools/screenshots/user_avatars/ManvirSingh.png",
"Dal Kim": "tools/screenshots/user_avatars/DalKim.jpg",
"John Lin": "tools/screenshots/user_avatars/JohnLin.png",
"Maxy Stert": "tools/screenshots/user_avatars/MaxyStert.jpg",
}
class MessageThread(BaseModel):
model_config = ConfigDict(frozen=True)
sender: str
content: str
starred: bool
edited: bool
reactions: dict[str, list[str]]
date: dict[str, int]
def create_user(full_name: str, avatar_filename: str | None) -> None:
email = f"{full_name.replace(' ', '').replace('í', 'i')}@zulip.com"
try:
user = UserProfile.objects.get(realm=realm, full_name=full_name)
except UserProfile.DoesNotExist:
user = do_create_user(email, "password", realm, full_name, acting_user=DEFAULT_USER)
if avatar_filename is not None:
set_avatar(user, avatar_filename)
def set_avatar(user: UserProfile, filename: str) -> None:
content_type = guess_type(filename)[0]
assert content_type is not None
with open(filename, "rb") as f:
upload_avatar_image(f, user, content_type=content_type)
do_change_avatar_fields(user, UserProfile.AVATAR_FROM_USER, acting_user=DEFAULT_USER)
def create_and_subscribe_stream(
stream_name: str, users: list[str], color: str | None = None, invite_only: bool = False
) -> None:
stream = ensure_stream(realm, stream_name, invite_only=invite_only, acting_user=DEFAULT_USER)
bulk_add_subscriptions(
realm,
[stream],
list(UserProfile.objects.filter(realm=realm, full_name__in=users)),
acting_user=DEFAULT_USER,
)
(stream, sub) = access_stream_by_id(DEFAULT_USER, stream.id)
assert sub is not None
if color is not None:
do_change_subscription_property(
DEFAULT_USER, sub, stream, "color", color, acting_user=DEFAULT_USER
)
def send_stream_messages(
stream_name: str, topic: str, staged_messages_data: list[MessageThread]
) -> list[int]:
staged_messages = [dict(staged_message) for staged_message in staged_messages_data]
stream = ensure_stream(realm, stream_name, acting_user=DEFAULT_USER)
subscribers_query = get_active_subscriptions_for_stream_id(
stream.id, include_deactivated_users=False
).values_list("user_profile", flat=True)
subscribers: dict[str, UserProfile] = {}
for subscriber_id in subscribers_query:
subscriber = UserProfile.objects.get(realm=realm, id=subscriber_id)
subscribers[subscriber.full_name] = subscriber
subscribers["Notification Bot"] = NOTIFICATION_BOT
messages: list[SendMessageRequest | None] = []
for message in staged_messages:
date_sent = message["date"]
message_request = internal_prep_stream_message(
subscribers[message["sender"]],
stream,
topic,
message["content"],
forged=True,
forged_timestamp=datetime_to_timestamp(
datetime(
date_sent["year"],
date_sent["month"],
date_sent["day"],
date_sent["hour"],
date_sent["minute"],
tzinfo=timezone.utc,
)
),
)
messages.append(message_request)
message_ids = [
sent_message_result.message_id for sent_message_result in do_send_messages(messages)
]
global message_thread_ids
message_thread_ids += message_ids
for message, message_id in zip(staged_messages, message_ids, strict=False):
if message.get("reactions") is not None:
reactions = message["reactions"]
for reaction, user_names in reactions.items():
users = [subscribers[user_name] for user_name in user_names]
add_message_reactions(message_id, reaction, users)
if message.get("starred"):
do_update_message_flags(DEFAULT_USER, "add", "starred", [message_id])
if message.get("edited"):
sender = UserProfile.objects.get(realm=realm, full_name=message["sender"])
updated_content = message["content"] + " "
check_update_message(sender, message_id, content=updated_content)
return message_ids
def add_message_reactions(message_id: int, emoji: str, users: list[UserProfile]) -> None:
preview_message = access_message(
user_profile=DEFAULT_USER, message_id=message_id, is_modifying_message=False
)
emoji_data = get_emoji_data(realm.id, emoji)
for user in users:
do_add_reaction(
user, preview_message, emoji, emoji_data.emoji_code, emoji_data.reaction_type
)
def create_user_group(group_name: str, members: list[str]) -> None:
member_profiles = [
UserProfile.objects.get(realm=realm, full_name=member_name) for member_name in members
]
member_profiles.append(DEFAULT_USER)
check_add_user_group(realm, group_name, member_profiles, acting_user=DEFAULT_USER)
def capture_streams_narrow_screenshot(
image_path: str, stream_name: str, topic: str, unread_msg_id: int
) -> None:
stream = ensure_stream(realm, stream_name, acting_user=DEFAULT_USER)
narrow_uri = topic_narrow_url(realm=realm, stream=stream, topic_name=topic)
narrow = f"{stream_name}/{topic}"
screenshot_script = os.path.join(SCREENSHOTS_DIR, "thread-screenshot.ts")
subprocess.check_call(
["node", screenshot_script, narrow_uri, narrow, str(unread_msg_id), image_path, realm.url]
)
DESCRIPTION = """
Generate screenshots of messages for corporate pages.
This script takes a json file with conversation details, and runs
tools/thread-screenshot.ts to take cropped screenshots of the
generated messages with puppeteer.
Make sure you have the dev environment up and running in a separate
terminal window when you run the script.
Note that you will often want to update the content of existing
json files (e.g., tools/screenshots/for-events.json) when you are
taking updated screenshots. For example, updating any dates for
the current year is recommended.
Also, note that you may need to adjust the viewport width in the
puppeteer code and/or update the channel or topic names in the json
content so that message header bars don't have truncated (...)
channel or topic names due to web app UI changes.
"""
parser = argparse.ArgumentParser(
description=DESCRIPTION, formatter_class=argparse.RawTextHelpFormatter
)
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument(
"--thread",
nargs="+",
help="Path of the file where the thread for screenshot is present",
)
fixture_group = parser.add_argument_group("thread")
options = parser.parse_args()
prepare_puppeteer_run()
try:
realm = get_realm("zulip")
with open(options.thread[0]) as f:
threads = json.load(f)
for thread in threads:
for user in thread["users"]:
user_avatar = USER_AVATARS_MAP.get(user)
create_user(user, user_avatar)
if thread["recipient_type"] == "channel":
users = list(thread["users"].keys())
users.append("Iago")
if thread.get("user_groups") is not None:
for user_group in thread.get("user_groups"):
user_grp = NamedUserGroup.objects.filter(
name=user_group["group_name"], realm_for_sharding=realm
).first()
if user_grp is not None:
user_grp.delete()
create_user_group(user_group["group_name"], user_group["members"])
invite_only = (
False if thread.get("invite_only") is None else thread.get("invite_only")
)
create_and_subscribe_stream(thread["channel"], users, thread["color"], invite_only)
message_ids = send_stream_messages(
thread["channel"], thread["topic"], thread["messages"]
)
capture_streams_narrow_screenshot(
thread["screenshot"], thread["channel"], thread["topic"], min(message_ids)
)
finally:
Message.objects.filter(id__in=message_thread_ids).delete()

View File

@@ -0,0 +1,99 @@
/* global CSS, zulip_test */
import * as assert from "node:assert/strict";
import * as fs from "node:fs";
import path from "node:path";
import {parseArgs} from "node:util";
import "css.escape";
import * as puppeteer from "puppeteer";
import * as z from "zod/mini";
const usage = "Usage: message-screenshot.ts <message_id> <image_path> <realm_url>";
const {
values: {help},
positionals,
} = parseArgs({options: {help: {type: "boolean"}}, allowPositionals: true});
if (help) {
console.log(usage);
process.exit(0);
}
const parsed = z
.tuple([
z.string(),
z.templateLiteral([z.string(), z.enum([".png", ".jpeg", ".webp"])]),
z.url(),
])
.safeParse(positionals);
if (!parsed.success) {
console.error(usage);
process.exit(1);
}
const [messageId, imagePath, realmUrl] = parsed.data;
console.log(`Capturing screenshot for message ${messageId} to ${imagePath}`);
// TODO: Refactor to share code with web/e2e-tests/realm-creation.test.ts
async function run(): Promise<void> {
const browser = await puppeteer.launch({
args: [
"--window-size=1400,1024",
"--no-sandbox",
"--disable-setuid-sandbox",
// Helps render fonts correctly on Ubuntu: https://github.com/puppeteer/puppeteer/issues/661
"--font-render-hinting=none",
],
defaultViewport: null,
headless: true,
});
try {
const page = await browser.newPage();
// deviceScaleFactor:2 gives better quality screenshots (higher pixel density)
await page.setViewport({width: 1280, height: 1024, deviceScaleFactor: 2});
await page.goto(`${realmUrl}/devlogin`);
// wait for Iago devlogin button and click on it.
await page.waitForSelector('[value="iago@zulip.com"]');
// By waiting till DOMContentLoaded we're confirming that Iago is logged in.
await Promise.all([
page.waitForNavigation({waitUntil: "domcontentloaded"}),
page.click('[value="iago@zulip.com"]'),
]);
// Navigate to message and capture screenshot
await page.goto(`${realmUrl}/#narrow/id/${messageId}`, {
waitUntil: "networkidle2",
});
const message_list_id = await page.evaluate(() => zulip_test.current_msg_list?.id);
assert.ok(message_list_id !== undefined);
const messageSelector = `#message-row-${message_list_id}-${CSS.escape(messageId)}`;
await page.waitForSelector(messageSelector);
// remove unread marker and don't select message
const marker = `#message-row-${message_list_id}-${CSS.escape(messageId)} .unread_marker`;
await page.$eval(marker, (element) => {
element.remove();
});
const messageBox = await page.$(messageSelector);
assert.ok(messageBox !== null);
await page.$eval(messageSelector, (element) => {
element.classList.remove("selected_message");
});
const messageGroup = await messageBox.$("xpath/..");
assert.ok(messageGroup !== null);
// Compute screenshot area, with some padding around the message group
const box = await messageGroup.boundingBox();
assert.ok(box !== null);
const imageDir = path.dirname(imagePath);
await fs.promises.mkdir(imageDir, {recursive: true});
await page.screenshot({
path: imagePath,
clip: {x: box.x - 5, y: box.y + 5, width: box.width + 10, height: box.height},
});
} finally {
await browser.close();
}
}
await run();

View File

@@ -0,0 +1,126 @@
/* global CSS, zulip_test */
import * as assert from "node:assert/strict";
import * as fs from "node:fs";
import path from "node:path";
import {parseArgs} from "node:util";
import "css.escape";
import * as puppeteer from "puppeteer";
import * as z from "zod/mini";
const usage =
"Usage: thread-screenshot.ts <narrow_uri> <narrow> <message_id> <image_path> <realm_url>";
const {
values: {help},
positionals,
} = parseArgs({options: {help: {type: "boolean"}}, allowPositionals: true});
if (help) {
console.log(usage);
process.exit(0);
}
const parsed = z
.tuple([
z.url(),
z.string(),
z.string(),
z.templateLiteral([z.string(), z.enum([".png", ".jpeg", ".webp"])]),
z.url(),
])
.safeParse(positionals);
if (!parsed.success) {
console.error(usage);
process.exit(1);
}
const [narrowUri, narrow, messageId, imagePath, realmUrl] = parsed.data;
console.log(`Capturing screenshot for ${narrow} to ${imagePath}`);
// TODO: Refactor to share code with web/e2e-tests/realm-creation.test.ts
async function run(): Promise<void> {
const browser = await puppeteer.launch({
args: [
"--window-size=500,1024",
"--no-sandbox",
"--disable-setuid-sandbox",
// Helps render fonts correctly on Ubuntu: https://github.com/puppeteer/puppeteer/issues/661
"--font-render-hinting=none",
],
defaultViewport: null,
headless: true,
});
try {
const page = await browser.newPage();
// deviceScaleFactor:2 gives better quality screenshots (higher pixel density)
await page.setViewport({width: 580, height: 1024, deviceScaleFactor: 2});
await page.goto(`${realmUrl}/devlogin`);
// wait for Iago devlogin button and click on it.
await page.waitForSelector('[value="iago@zulip.com"]');
// By waiting till DOMContentLoaded we're confirming that Iago is logged in.
await Promise.all([
page.waitForNavigation({waitUntil: "domcontentloaded"}),
page.click('[value="iago@zulip.com"]'),
]);
// Close any banner at the top of the app before taking any screenshots.
const top_banner_close_button_selector = ".banner-close-action";
await page.waitForSelector(top_banner_close_button_selector);
await page.click(top_banner_close_button_selector);
// Navigate to message and capture screenshot
await page.goto(narrowUri, {
waitUntil: "networkidle2",
});
const message_list_id = await page.evaluate(() => zulip_test.current_msg_list?.id);
assert.ok(message_list_id !== undefined);
const messageListSelector = "#message-lists-container";
await page.waitForSelector(messageListSelector);
// remove unread marker and don't select message
const marker = `.message-list[data-message-list-id="${CSS.escape(
`${message_list_id}`,
)}"] .unread_marker`;
await page.$eval(marker, (element) => {
element.remove();
});
const messageSelector = `#message-row-${message_list_id}-${CSS.escape(messageId)}`;
await page.waitForSelector(messageSelector);
const messageListBox = await page.$(messageListSelector);
assert.ok(messageListBox !== null);
await page.$eval(messageSelector, (element) => {
element.classList.remove("selected_message");
});
// This is done so as to get white background while capturing screenshots.
const background_selectors = [".app-main", ".message-feed", ".message_header"];
await page.$$eval(background_selectors.join(","), (elements) => {
for (const element of elements) {
if (element instanceof HTMLElement) {
element.style.backgroundColor = "white";
}
}
});
// This is done so that the message control buttons are not visible.
await page.hover(".compose_new_conversation_button");
// Compute screenshot area, with some padding around the message group
const box = await messageListBox.boundingBox();
assert.ok(box !== null);
const imageDir = path.dirname(imagePath);
await fs.promises.mkdir(imageDir, {recursive: true});
await page.screenshot({
path: imagePath,
clip: {x: box.x, y: box.y + 10, width: box.width - 70, height: box.height - 8},
});
} finally {
await browser.close();
}
}
await run();

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB