__filename__ = "webinterface.py"
__author__ = "Bob Mottram"
__license__ = "AGPL3+"
__version__ = "1.1.0"
__maintainer__ = "Bob Mottram"
__email__ = "bob@freedombone.net"
__status__ = "Production"

import time
import os
import urllib.parse
from collections import OrderedDict
from datetime import datetime
from datetime import date
from dateutil.parser import parse
from shutil import copyfile
from pprint import pprint
from person import personBoxJson
from person import isPersonSnoozed
from pgp import getEmailAddress
from pgp import getPGPpubKey
from pgp import getPGPfingerprint
from xmpp import getXmppAddress
from ssb import getSSBAddress
from tox import getToxAddress
from matrix import getMatrixAddress
from donate import getDonationUrl
from utils import removeIdEnding
from utils import getProtocolPrefixes
from utils import searchBoxPosts
from utils import isEventPost
from utils import isBlogPost
from utils import updateRecentPostsCache
from utils import getNicknameFromActor
from utils import getDomainFromActor
from utils import locatePost
from utils import noOfAccounts
from utils import isPublicPost
from utils import isPublicPostFromUrl
from utils import getDisplayName
from utils import getCachedPostDirectory
from utils import getCachedPostFilename
from utils import loadJson
from follow import isFollowingActor
from webfinger import webfingerHandle
from posts import isDM
from posts import getPersonBox
from posts import getUserUrl
from posts import parseUserFeed
from posts import populateRepliesJson
from posts import isModerator
from posts import downloadAnnounce
from session import getJson
from auth import createPassword
from like import likedByPerson
from like import noOfLikes
from bookmarks import bookmarkedByPerson
from announce import announcedByPerson
from blocking import isBlocked
from blocking import isBlockedHashtag
from content import htmlReplaceQuoteMarks
from content import removeTextFormatting
from content import switchWords
from content import getMentionsFromHtml
from content import addHtmlTags
from content import replaceEmojiFromTags
from content import removeLongWords
from content import removeHtml
from config import getConfigParam
from skills import getSkills
from cache import getPersonFromCache
from cache import storePersonInCache
from shares import getValidSharedItemID
from happening import todaysEventsCheck
from happening import thisWeeksEventsCheck
from happening import getCalendarEvents
from happening import getTodaysEvents
from git import isGitPatch
from theme import getThemesList
from petnames import getPetName
from followingCalendar import receivingCalendarEvents
from devices import E2EEdecryptMessageFromDevice


def getAltPath(actor: str, domainFull: str, callingDomain: str) -> str:
    """Returns alternate path from the actor
    eg. https://clearnetdomain/path becomes http://oniondomain/path
    """
    postActor = actor
    if callingDomain not in actor and domainFull in actor:
        if callingDomain.endswith('.onion') or \
           callingDomain.endswith('.i2p'):
            postActor = \
                'http://' + callingDomain + actor.split(domainFull)[1]
            print('Changed POST domain from ' + actor + ' to ' + postActor)
    return postActor


def getContentWarningButton(postID: str, translate: {},
                            content: str) -> str:
    """Returns the markup for a content warning button
    """
    return '<details><summary><b>' + \
        translate['SHOW MORE'] + '</b></summary>' + \
        '<div id="' + postID + '">' + content + \
        '</div></details>'


def getBlogAddress(actorJson: {}) -> str:
    """Returns blog address for the given actor
    """
    if not actorJson.get('attachment'):
        return ''
    for propertyValue in actorJson['attachment']:
        if not propertyValue.get('name'):
            continue
        if not propertyValue['name'].lower().startswith('blog'):
            continue
        if not propertyValue.get('type'):
            continue
        if not propertyValue.get('value'):
            continue
        if propertyValue['type'] != 'PropertyValue':
            continue
        propertyValue['value'] = propertyValue['value'].strip()
        prefixes = getProtocolPrefixes()
        prefixFound = False
        for prefix in prefixes:
            if propertyValue['value'].startswith(prefix):
                prefixFound = True
                break
        if not prefixFound:
            continue
        if '.' not in propertyValue['value']:
            continue
        if ' ' in propertyValue['value']:
            continue
        if ',' in propertyValue['value']:
            continue
        return propertyValue['value']
    return ''


def setBlogAddress(actorJson: {}, blogAddress: str) -> None:
    """Sets an blog address for the given actor
    """
    if not actorJson.get('attachment'):
        actorJson['attachment'] = []

    # remove any existing value
    propertyFound = None
    for propertyValue in actorJson['attachment']:
        if not propertyValue.get('name'):
            continue
        if not propertyValue.get('type'):
            continue
        if not propertyValue['name'].lower().startswith('blog'):
            continue
        propertyFound = propertyValue
        break
    if propertyFound:
        actorJson['attachment'].remove(propertyFound)

    prefixes = getProtocolPrefixes()
    prefixFound = False
    for prefix in prefixes:
        if blogAddress.startswith(prefix):
            prefixFound = True
            break
    if not prefixFound:
        return
    if '.' not in blogAddress:
        return
    if ' ' in blogAddress:
        return
    if ',' in blogAddress:
        return

    for propertyValue in actorJson['attachment']:
        if not propertyValue.get('name'):
            continue
        if not propertyValue.get('type'):
            continue
        if not propertyValue['name'].lower().startswith('blog'):
            continue
        if propertyValue['type'] != 'PropertyValue':
            continue
        propertyValue['value'] = blogAddress
        return

    newBlogAddress = {
        "name": "Blog",
        "type": "PropertyValue",
        "value": blogAddress
    }
    actorJson['attachment'].append(newBlogAddress)


def updateAvatarImageCache(session, baseDir: str, httpPrefix: str,
                           actor: str, avatarUrl: str,
                           personCache: {}, allowDownloads: bool,
                           force=False) -> str:
    """Updates the cached avatar for the given actor
    """
    if not avatarUrl:
        return None
    actorStr = actor.replace('/', '-')
    avatarImagePath = baseDir + '/cache/avatars/' + actorStr
    if avatarUrl.endswith('.png') or \
       '.png?' in avatarUrl:
        sessionHeaders = {
            'Accept': 'image/png'
        }
        avatarImageFilename = avatarImagePath + '.png'
    elif (avatarUrl.endswith('.jpg') or
          avatarUrl.endswith('.jpeg') or
          '.jpg?' in avatarUrl or
          '.jpeg?' in avatarUrl):
        sessionHeaders = {
            'Accept': 'image/jpeg'
        }
        avatarImageFilename = avatarImagePath + '.jpg'
    elif avatarUrl.endswith('.gif') or '.gif?' in avatarUrl:
        sessionHeaders = {
            'Accept': 'image/gif'
        }
        avatarImageFilename = avatarImagePath + '.gif'
    elif avatarUrl.endswith('.webp') or '.webp?' in avatarUrl:
        sessionHeaders = {
            'Accept': 'image/webp'
        }
        avatarImageFilename = avatarImagePath + '.webp'
    else:
        return None

    if (not os.path.isfile(avatarImageFilename) or force) and allowDownloads:
        try:
            print('avatar image url: ' + avatarUrl)
            result = session.get(avatarUrl,
                                 headers=sessionHeaders,
                                 params=None)
            if result.status_code < 200 or \
               result.status_code > 202:
                print('Avatar image download failed with status ' +
                      str(result.status_code))
                # remove partial download
                if os.path.isfile(avatarImageFilename):
                    os.remove(avatarImageFilename)
            else:
                with open(avatarImageFilename, 'wb') as f:
                    f.write(result.content)
                    print('avatar image downloaded for ' + actor)
                    return avatarImageFilename.replace(baseDir + '/cache', '')
        except Exception as e:
            print('Failed to download avatar image: ' + str(avatarUrl))
            print(e)
        prof = 'https://www.w3.org/ns/activitystreams'
        if '/channel/' not in actor or '/accounts/' not in actor:
            sessionHeaders = {
                'Accept': 'application/activity+json; profile="' + prof + '"'
            }
        else:
            sessionHeaders = {
                'Accept': 'application/ld+json; profile="' + prof + '"'
            }
        personJson = \
            getJson(session, actor, sessionHeaders, None, __version__,
                    httpPrefix, None)
        if personJson:
            if not personJson.get('id'):
                return None
            if not personJson.get('publicKey'):
                return None
            if not personJson['publicKey'].get('publicKeyPem'):
                return None
            if personJson['id'] != actor:
                return None
            if not personCache.get(actor):
                return None
            if personCache[actor]['actor']['publicKey']['publicKeyPem'] != \
               personJson['publicKey']['publicKeyPem']:
                print("ERROR: " +
                      "public keys don't match when downloading actor for " +
                      actor)
                return None
            storePersonInCache(baseDir, actor, personJson, personCache,
                               allowDownloads)
            return getPersonAvatarUrl(baseDir, actor, personCache,
                                      allowDownloads)
        return None
    return avatarImageFilename.replace(baseDir + '/cache', '')


def getPersonAvatarUrl(baseDir: str, personUrl: str, personCache: {},
                       allowDownloads: bool) -> str:
    """Returns the avatar url for the person
    """
    personJson = \
        getPersonFromCache(baseDir, personUrl, personCache, allowDownloads)
    if not personJson:
        return None

    # get from locally stored image
    actorStr = personJson['id'].replace('/', '-')
    avatarImagePath = baseDir + '/cache/avatars/' + actorStr

    imageExtension = ('png', 'jpg', 'jpeg', 'gif', 'webp')
    for ext in imageExtension:
        if os.path.isfile(avatarImagePath + '.' + ext):
            return '/avatars/' + actorStr + '.' + ext
        elif os.path.isfile(avatarImagePath.lower() + '.' + ext):
            return '/avatars/' + actorStr.lower() + '.' + ext

    if personJson.get('icon'):
        if personJson['icon'].get('url'):
            return personJson['icon']['url']
    return None


def htmlFollowingList(baseDir: str, followingFilename: str) -> str:
    """Returns a list of handles being followed
    """
    with open(followingFilename, 'r') as followingFile:
        msg = followingFile.read()
        followingList = msg.split('\n')
        followingList.sort()
        if followingList:
            cssFilename = baseDir + '/epicyon-profile.css'
            if os.path.isfile(baseDir + '/epicyon.css'):
                cssFilename = baseDir + '/epicyon.css'
            with open(cssFilename, 'r') as cssFile:
                profileCSS = cssFile.read()
                followingListHtml = htmlHeader(cssFilename, profileCSS)
                for followingAddress in followingList:
                    if followingAddress:
                        followingListHtml += \
                            '<h3>@' + followingAddress + '</h3>'
                followingListHtml += htmlFooter()
                msg = followingListHtml
        return msg
    return ''


def htmlFollowingDataList(baseDir: str, nickname: str,
                          domain: str, domainFull: str) -> str:
    """Returns a datalist of handles being followed
    """
    listStr = '<datalist id="followingHandles">\n'
    followingFilename = \
        baseDir + '/accounts/' + nickname + '@' + domain + '/following.txt'
    if os.path.isfile(followingFilename):
        with open(followingFilename, 'r') as followingFile:
            msg = followingFile.read()
            # add your own handle, so that you can send DMs
            # to yourself as reminders
            msg += nickname + '@' + domainFull + '\n'
            # include petnames
            petnamesFilename = \
                baseDir + '/accounts/' + \
                nickname + '@' + domain + '/petnames.txt'
            if os.path.isfile(petnamesFilename):
                followingList = []
                with open(petnamesFilename, 'r') as petnamesFile:
                    petStr = petnamesFile.read()
                    # extract each petname and append it
                    petnamesList = petStr.split('\n')
                    for pet in petnamesList:
                        followingList.append(pet.split(' ')[0])
                # add the following.txt entries
                followingList += msg.split('\n')
            else:
                # no petnames list exists - just use following.txt
                followingList = msg.split('\n')
            followingList.sort()
            if followingList:
                for followingAddress in followingList:
                    if followingAddress:
                        listStr += \
                            '<option>@' + followingAddress + '</option>\n'
    listStr += '</datalist>\n'
    return listStr


def htmlSearchEmoji(translate: {}, baseDir: str, httpPrefix: str,
                    searchStr: str) -> str:
    """Search results for emoji
    """
    # emoji.json is generated so that it can be customized and the changes
    # will be retained even if default_emoji.json is subsequently updated
    if not os.path.isfile(baseDir + '/emoji/emoji.json'):
        copyfile(baseDir + '/emoji/default_emoji.json',
                 baseDir + '/emoji/emoji.json')

    searchStr = searchStr.lower().replace(':', '').strip('\n').strip('\r')
    cssFilename = baseDir + '/epicyon-profile.css'
    if os.path.isfile(baseDir + '/epicyon.css'):
        cssFilename = baseDir + '/epicyon.css'
    with open(cssFilename, 'r') as cssFile:
        emojiCSS = cssFile.read()
        if httpPrefix != 'https':
            emojiCSS = emojiCSS.replace('https://',
                                        httpPrefix + '://')
        emojiLookupFilename = baseDir + '/emoji/emoji.json'

        # create header
        emojiForm = htmlHeader(cssFilename, emojiCSS)
        emojiForm += '<center><h1>' + \
            translate['Emoji Search'] + \
            '</h1></center>'

        # does the lookup file exist?
        if not os.path.isfile(emojiLookupFilename):
            emojiForm += '<center><h5>' + \
                translate['No results'] + '</h5></center>'
            emojiForm += htmlFooter()
            return emojiForm

        emojiJson = loadJson(emojiLookupFilename)
        if emojiJson:
            results = {}
            for emojiName, filename in emojiJson.items():
                if searchStr in emojiName:
                    results[emojiName] = filename + '.png'
            for emojiName, filename in emojiJson.items():
                if emojiName in searchStr:
                    results[emojiName] = filename + '.png'
            headingShown = False
            emojiForm += '<center>'
            msgStr1 = translate['Copy the text then paste it into your post']
            msgStr2 = ':<img loading="lazy" class="searchEmoji" src="/emoji/'
            for emojiName, filename in results.items():
                if os.path.isfile(baseDir + '/emoji/' + filename):
                    if not headingShown:
                        emojiForm += \
                            '<center><h5>' + msgStr1 + \
                            '</h5></center>'
                        headingShown = True
                    emojiForm += \
                        '<h3>:' + emojiName + msgStr2 + \
                        filename + '"/></h3>'
            emojiForm += '</center>'

        emojiForm += htmlFooter()
    return emojiForm


def getIconsDir(baseDir: str) -> str:
    """Returns the directory where icons exist
    """
    iconsDir = 'icons'
    theme = getConfigParam(baseDir, 'theme')
    if theme:
        if os.path.isdir(baseDir + '/img/icons/' + theme):
            iconsDir = 'icons/' + theme
    return iconsDir


def htmlSearchSharedItems(translate: {},
                          baseDir: str, searchStr: str,
                          pageNumber: int,
                          resultsPerPage: int,
                          httpPrefix: str,
                          domainFull: str, actor: str,
                          callingDomain: str) -> str:
    """Search results for shared items
    """
    iconsDir = getIconsDir(baseDir)
    currPage = 1
    ctr = 0
    sharedItemsForm = ''
    searchStrLower = urllib.parse.unquote(searchStr)
    searchStrLower = searchStrLower.lower().strip('\n').strip('\r')
    searchStrLowerList = searchStrLower.split('+')
    cssFilename = baseDir + '/epicyon-profile.css'
    if os.path.isfile(baseDir + '/epicyon.css'):
        cssFilename = baseDir + '/epicyon.css'

    with open(cssFilename, 'r') as cssFile:
        sharedItemsCSS = cssFile.read()
        if httpPrefix != 'https':
            sharedItemsCSS = \
                sharedItemsCSS.replace('https://',
                                       httpPrefix + '://')
        sharedItemsForm = htmlHeader(cssFilename, sharedItemsCSS)
        sharedItemsForm += \
            '<center><h1>' + translate['Shared Items Search'] + \
            '</h1></center>'
        resultsExist = False
        for subdir, dirs, files in os.walk(baseDir + '/accounts'):
            for handle in dirs:
                if '@' not in handle:
                    continue
                contactNickname = handle.split('@')[0]
                sharesFilename = baseDir + '/accounts/' + handle + \
                    '/shares.json'
                if not os.path.isfile(sharesFilename):
                    continue

                sharesJson = loadJson(sharesFilename)
                if not sharesJson:
                    continue

                for name, sharedItem in sharesJson.items():
                    matched = True
                    for searchSubstr in searchStrLowerList:
                        subStrMatched = False
                        searchSubstr = searchSubstr.strip()
                        if searchSubstr in sharedItem['location'].lower():
                            subStrMatched = True
                        elif searchSubstr in sharedItem['summary'].lower():
                            subStrMatched = True
                        elif searchSubstr in sharedItem['displayName'].lower():
                            subStrMatched = True
                        elif searchSubstr in sharedItem['category'].lower():
                            subStrMatched = True
                        if not subStrMatched:
                            matched = False
                            break
                    if matched:
                        if currPage == pageNumber:
                            sharedItemsForm += '<div class="container">'
                            sharedItemsForm += \
                                '<p class="share-title">' + \
                                sharedItem['displayName'] + '</p>'
                            if sharedItem.get('imageUrl'):
                                sharedItemsForm += \
                                    '<a href="' + \
                                    sharedItem['imageUrl'] + '">'
                                sharedItemsForm += \
                                    '<img loading="lazy" src="' + \
                                    sharedItem['imageUrl'] + \
                                    '" alt="Item image"></a>'
                            sharedItemsForm += \
                                '<p>' + sharedItem['summary'] + '</p>'
                            sharedItemsForm += \
                                '<p><b>' + translate['Type'] + \
                                ':</b> ' + sharedItem['itemType'] + ' '
                            sharedItemsForm += \
                                '<b>' + translate['Category'] + \
                                ':</b> ' + sharedItem['category'] + ' '
                            sharedItemsForm += \
                                '<b>' + translate['Location'] + \
                                ':</b> ' + sharedItem['location'] + '</p>'
                            contactActor = \
                                httpPrefix + '://' + domainFull + \
                                '/users/' + contactNickname
                            sharedItemsForm += \
                                '<p><a href="' + actor + \
                                '?replydm=sharedesc:' + \
                                sharedItem['displayName'] + \
                                '?mention=' + contactActor + \
                                '"><button class="button">' + \
                                translate['Contact'] + '</button></a>'
                            if actor.endswith('/users/' + contactNickname):
                                sharedItemsForm += \
                                    ' <a href="' + actor + '?rmshare=' + \
                                    name + '"><button class="button">' + \
                                    translate['Remove'] + '</button></a>'
                            sharedItemsForm += '</p></div>'
                            if not resultsExist and currPage > 1:
                                postActor = \
                                    getAltPath(actor, domainFull,
                                               callingDomain)
                                # previous page link, needs to be a POST
                                sharedItemsForm += \
                                    '<form method="POST" action="' + \
                                    postActor + \
                                    '/searchhandle?page=' + \
                                    str(pageNumber - 1) + '">'
                                sharedItemsForm += \
                                    '  <input type="hidden" ' + \
                                    'name="actor" value="' + actor + '">'
                                sharedItemsForm += \
                                    '  <input type="hidden" ' + \
                                    'name="searchtext" value="' + \
                                    searchStrLower + '"><br>'
                                sharedItemsForm += \
                                    '  <center><a href="' + actor + \
                                    '" type="submit" name="submitSearch">'
                                sharedItemsForm += \
                                    '    <img loading="lazy" ' + \
                                    'class="pageicon" src="/' + iconsDir + \
                                    '/pageup.png" title="' + \
                                    translate['Page up'] + \
                                    '" alt="' + translate['Page up'] + \
                                    '"/></a>'
                                sharedItemsForm += '  </center>'
                                sharedItemsForm += '</form>'
                                resultsExist = True
                        ctr += 1
                        if ctr >= resultsPerPage:
                            currPage += 1
                            if currPage > pageNumber:
                                postActor = \
                                    getAltPath(actor, domainFull,
                                               callingDomain)
                                # next page link, needs to be a POST
                                sharedItemsForm += \
                                    '<form method="POST" action="' + \
                                    postActor + \
                                    '/searchhandle?page=' + \
                                    str(pageNumber + 1) + '">'
                                sharedItemsForm += \
                                    '  <input type="hidden" ' + \
                                    'name="actor" value="' + actor + '">'
                                sharedItemsForm += \
                                    '  <input type="hidden" ' + \
                                    'name="searchtext" value="' + \
                                    searchStrLower + '"><br>'
                                sharedItemsForm += \
                                    '  <center><a href="' + actor + \
                                    '" type="submit" name="submitSearch">'
                                sharedItemsForm += \
                                    '    <img loading="lazy" ' + \
                                    'class="pageicon" src="/' + iconsDir + \
                                    '/pagedown.png" title="' + \
                                    translate['Page down'] + \
                                    '" alt="' + translate['Page down'] + \
                                    '"/></a>'
                                sharedItemsForm += '  </center>'
                                sharedItemsForm += '</form>'
                                break
                            ctr = 0
        if not resultsExist:
            sharedItemsForm += \
                '<center><h5>' + translate['No results'] + '</h5></center>'
        sharedItemsForm += htmlFooter()
    return sharedItemsForm


def htmlModerationInfo(translate: {}, baseDir: str, httpPrefix: str) -> str:
    msgStr1 = \
        'These are globally blocked for all accounts on this instance'
    msgStr2 = \
        'Any blocks or suspensions made by moderators will be shown here.'
    infoForm = ''
    cssFilename = baseDir + '/epicyon-profile.css'
    if os.path.isfile(baseDir + '/epicyon.css'):
        cssFilename = baseDir + '/epicyon.css'
    with open(cssFilename, 'r') as cssFile:
        infoCSS = cssFile.read()
        if httpPrefix != 'https':
            infoCSS = infoCSS.replace('https://',
                                      httpPrefix + '://')
        infoForm = htmlHeader(cssFilename, infoCSS)

        infoForm += \
            '<center><h1>' + \
            translate['Moderation Information'] + \
            '</h1></center>'

        infoShown = False
        suspendedFilename = baseDir + '/accounts/suspended.txt'
        if os.path.isfile(suspendedFilename):
            with open(suspendedFilename, "r") as f:
                suspendedStr = f.read()
                infoForm += '<div class="container">'
                infoForm += '  <br><b>' + \
                    translate['Suspended accounts'] + '</b>'
                infoForm += '  <br>' + \
                    translate['These are currently suspended']
                infoForm += \
                    '  <textarea id="message" ' + \
                    'name="suspended" style="height:200px">' + \
                    suspendedStr + '</textarea>'
                infoForm += '</div>'
                infoShown = True

        blockingFilename = baseDir + '/accounts/blocking.txt'
        if os.path.isfile(blockingFilename):
            with open(blockingFilename, "r") as f:
                blockedStr = f.read()
                infoForm += '<div class="container">'
                infoForm += \
                    '  <br><b>' + \
                    translate['Blocked accounts and hashtags'] + '</b>'
                infoForm += \
                    '  <br>' + \
                    translate[msgStr1]
                infoForm += \
                    '  <textarea id="message" ' + \
                    'name="blocked" style="height:400px">' + \
                    blockedStr + '</textarea>'
                infoForm += '</div>'
                infoShown = True
        if not infoShown:
            infoForm += \
                '<center><p>' + \
                translate[msgStr2] + \
                '</p></center>'
        infoForm += htmlFooter()
    return infoForm


def htmlHashtagSearch(nickname: str, domain: str, port: int,
                      recentPostsCache: {}, maxRecentPosts: int,
                      translate: {},
                      baseDir: str, hashtag: str, pageNumber: int,
                      postsPerPage: int,
                      session, wfRequest: {}, personCache: {},
                      httpPrefix: str, projectVersion: str,
                      YTReplacementDomain: str) -> str:
    """Show a page containing search results for a hashtag
    """
    if hashtag.startswith('#'):
        hashtag = hashtag[1:]
    hashtag = urllib.parse.unquote(hashtag)
    hashtagIndexFile = baseDir + '/tags/' + hashtag + '.txt'
    if not os.path.isfile(hashtagIndexFile):
        if hashtag != hashtag.lower():
            hashtag = hashtag.lower()
            hashtagIndexFile = baseDir + '/tags/' + hashtag + '.txt'
    if not os.path.isfile(hashtagIndexFile):
        print('WARN: hashtag file not found ' + hashtagIndexFile)
        return None

    iconsDir = getIconsDir(baseDir)

    # check that the directory for the nickname exists
    if nickname:
        if not os.path.isdir(baseDir + '/accounts/' +
                             nickname + '@' + domain):
            nickname = None

    # read the index
    with open(hashtagIndexFile, "r") as f:
        lines = f.readlines()

    # read the css
    cssFilename = baseDir + '/epicyon-profile.css'
    if os.path.isfile(baseDir + '/epicyon.css'):
        cssFilename = baseDir + '/epicyon.css'
    with open(cssFilename, 'r') as cssFile:
        hashtagSearchCSS = cssFile.read()
        if httpPrefix != 'https':
            hashtagSearchCSS = \
                hashtagSearchCSS.replace('https://',
                                         httpPrefix + '://')

    # ensure that the page number is in bounds
    if not pageNumber:
        pageNumber = 1
    elif pageNumber < 1:
        pageNumber = 1

    # get the start end end within the index file
    startIndex = int((pageNumber - 1) * postsPerPage)
    endIndex = startIndex + postsPerPage
    noOfLines = len(lines)
    if endIndex >= noOfLines and noOfLines > 0:
        endIndex = noOfLines - 1

    # add the page title
    hashtagSearchForm = htmlHeader(cssFilename, hashtagSearchCSS)
    if nickname:
        hashtagSearchForm += '<center>\n' + \
            '<h1><a href="/users/' + nickname + '/search">#' + \
            hashtag + '</a></h1>\n' + '</center>\n'
    else:
        hashtagSearchForm += '<center>\n' + \
            '<h1>#' + hashtag + '</h1>\n' + '</center>\n'

    if startIndex > 0:
        # previous page link
        hashtagSearchForm += \
            '<center><a href="/tags/' + hashtag + '?page=' + \
            str(pageNumber - 1) + \
            '"><img loading="lazy" class="pageicon" src="/' + \
            iconsDir + '/pageup.png" title="' + \
            translate['Page up'] + \
            '" alt="' + translate['Page up'] + \
            '"></a></center>\n'
    index = startIndex
    while index <= endIndex:
        postId = lines[index].strip('\n').strip('\r')
        if '  ' not in postId:
            nickname = getNicknameFromActor(postId)
            if not nickname:
                index += 1
                continue
        else:
            postFields = postId.split('  ')
            if len(postFields) != 3:
                index = +1
                continue
            nickname = postFields[1]
            postId = postFields[2]
        postFilename = locatePost(baseDir, nickname, domain, postId)
        if not postFilename:
            index += 1
            continue
        postJsonObject = loadJson(postFilename)
        if postJsonObject:
            if not isPublicPost(postJsonObject):
                index += 1
                continue
            showIndividualPostIcons = False
            if nickname:
                showIndividualPostIcons = True
            allowDeletion = False
            hashtagSearchForm += \
                individualPostAsHtml(True, recentPostsCache,
                                     maxRecentPosts,
                                     iconsDir, translate, None,
                                     baseDir, session, wfRequest,
                                     personCache,
                                     nickname, domain, port,
                                     postJsonObject,
                                     None, True, allowDeletion,
                                     httpPrefix, projectVersion,
                                     'search',
                                     YTReplacementDomain,
                                     showIndividualPostIcons,
                                     showIndividualPostIcons,
                                     False, False, False)
        index += 1

    if endIndex < noOfLines - 1:
        # next page link
        hashtagSearchForm += \
            '<center><a href="/tags/' + hashtag + \
            '?page=' + str(pageNumber + 1) + \
            '"><img loading="lazy" class="pageicon" src="/' + iconsDir + \
            '/pagedown.png" title="' + translate['Page down'] + \
            '" alt="' + translate['Page down'] + '"></a></center>'
    hashtagSearchForm += htmlFooter()
    return hashtagSearchForm


def htmlSkillsSearch(translate: {}, baseDir: str,
                     httpPrefix: str,
                     skillsearch: str, instanceOnly: bool,
                     postsPerPage: int) -> str:
    """Show a page containing search results for a skill
    """
    if skillsearch.startswith('*'):
        skillsearch = skillsearch[1:].strip()

    skillsearch = skillsearch.lower().strip('\n').strip('\r')

    results = []
    # search instance accounts
    for subdir, dirs, files in os.walk(baseDir + '/accounts/'):
        for f in files:
            if not f.endswith('.json'):
                continue
            if '@' not in f:
                continue
            if f.startswith('inbox@'):
                continue
            actorFilename = os.path.join(subdir, f)
            actorJson = loadJson(actorFilename)
            if actorJson:
                if actorJson.get('id') and \
                   actorJson.get('skills') and \
                   actorJson.get('name') and \
                   actorJson.get('icon'):
                    actor = actorJson['id']
                    for skillName, skillLevel in actorJson['skills'].items():
                        skillName = skillName.lower()
                        if not (skillName in skillsearch or
                                skillsearch in skillName):
                            continue
                        skillLevelStr = str(skillLevel)
                        if skillLevel < 100:
                            skillLevelStr = '0' + skillLevelStr
                        if skillLevel < 10:
                            skillLevelStr = '0' + skillLevelStr
                        indexStr = \
                            skillLevelStr + ';' + actor + ';' + \
                            actorJson['name'] + \
                            ';' + actorJson['icon']['url']
                        if indexStr not in results:
                            results.append(indexStr)
    if not instanceOnly:
        # search actor cache
        for subdir, dirs, files in os.walk(baseDir + '/cache/actors/'):
            for f in files:
                if not f.endswith('.json'):
                    continue
                if '@' not in f:
                    continue
                if f.startswith('inbox@'):
                    continue
                actorFilename = os.path.join(subdir, f)
                cachedActorJson = loadJson(actorFilename)
                if cachedActorJson:
                    if cachedActorJson.get('actor'):
                        actorJson = cachedActorJson['actor']
                        if actorJson.get('id') and \
                           actorJson.get('skills') and \
                           actorJson.get('name') and \
                           actorJson.get('icon'):
                            actor = actorJson['id']
                            for skillName, skillLevel in \
                                    actorJson['skills'].items():
                                skillName = skillName.lower()
                                if not (skillName in skillsearch or
                                        skillsearch in skillName):
                                    continue
                                skillLevelStr = str(skillLevel)
                                if skillLevel < 100:
                                    skillLevelStr = '0' + skillLevelStr
                                if skillLevel < 10:
                                    skillLevelStr = '0' + skillLevelStr
                                indexStr = \
                                    skillLevelStr + ';' + actor + ';' + \
                                    actorJson['name'] + \
                                    ';' + actorJson['icon']['url']
                                if indexStr not in results:
                                    results.append(indexStr)

    results.sort(reverse=True)

    cssFilename = baseDir + '/epicyon-profile.css'
    if os.path.isfile(baseDir + '/epicyon.css'):
        cssFilename = baseDir + '/epicyon.css'
    with open(cssFilename, 'r') as cssFile:
        skillSearchCSS = cssFile.read()
        if httpPrefix != 'https':
            skillSearchCSS = \
                skillSearchCSS.replace('https://',
                                       httpPrefix + '://')
    skillSearchForm = htmlHeader(cssFilename, skillSearchCSS)
    skillSearchForm += \
        '<center><h1>' + translate['Skills search'] + ': ' + \
        skillsearch + '</h1></center>'

    if len(results) == 0:
        skillSearchForm += \
            '<center><h5>' + translate['No results'] + \
            '</h5></center>'
    else:
        skillSearchForm += '<center>'
        ctr = 0
        for skillMatch in results:
            skillMatchFields = skillMatch.split(';')
            if len(skillMatchFields) != 4:
                continue
            actor = skillMatchFields[1]
            actorName = skillMatchFields[2]
            avatarUrl = skillMatchFields[3]
            skillSearchForm += \
                '<div class="search-result""><a href="' + \
                actor + '/skills">'
            skillSearchForm += \
                '<img loading="lazy" src="' + avatarUrl + \
                '"/><span class="search-result-text">' + actorName + \
                '</span></a></div>'
            ctr += 1
            if ctr >= postsPerPage:
                break
        skillSearchForm += '</center>'
    skillSearchForm += htmlFooter()
    return skillSearchForm


def htmlHistorySearch(translate: {}, baseDir: str,
                      httpPrefix: str,
                      nickname: str, domain: str,
                      historysearch: str,
                      postsPerPage: int, pageNumber: int,
                      projectVersion: str,
                      recentPostsCache: {},
                      maxRecentPosts: int,
                      session,
                      wfRequest,
                      personCache: {},
                      port: int,
                      YTReplacementDomain: str) -> str:
    """Show a page containing search results for your post history
    """
    if historysearch.startswith('!'):
        historysearch = historysearch[1:].strip()

    historysearch = historysearch.lower().strip('\n').strip('\r')

    boxFilenames = \
        searchBoxPosts(baseDir, nickname, domain,
                       historysearch, postsPerPage)

    cssFilename = baseDir + '/epicyon-profile.css'
    if os.path.isfile(baseDir + '/epicyon.css'):
        cssFilename = baseDir + '/epicyon.css'
    with open(cssFilename, 'r') as cssFile:
        historySearchCSS = cssFile.read()
        if httpPrefix != 'https':
            historySearchCSS = \
                historySearchCSS.replace('https://',
                                         httpPrefix + '://')
    historySearchForm = htmlHeader(cssFilename, historySearchCSS)

    # add the page title
    historySearchForm += \
        '<center><h1>' + translate['Your Posts'] + '</h1></center>'

    if len(boxFilenames) == 0:
        historySearchForm += \
            '<center><h5>' + translate['No results'] + \
            '</h5></center>'
        return historySearchForm

    iconsDir = getIconsDir(baseDir)

    # ensure that the page number is in bounds
    if not pageNumber:
        pageNumber = 1
    elif pageNumber < 1:
        pageNumber = 1

    # get the start end end within the index file
    startIndex = int((pageNumber - 1) * postsPerPage)
    endIndex = startIndex + postsPerPage
    noOfBoxFilenames = len(boxFilenames)
    if endIndex >= noOfBoxFilenames and noOfBoxFilenames > 0:
        endIndex = noOfBoxFilenames - 1

    index = startIndex
    while index <= endIndex:
        postFilename = boxFilenames[index]
        if not postFilename:
            index += 1
            continue
        postJsonObject = loadJson(postFilename)
        if not postJsonObject:
            index += 1
            continue
        showIndividualPostIcons = True
        allowDeletion = False
        historySearchForm += \
            individualPostAsHtml(True, recentPostsCache,
                                 maxRecentPosts,
                                 iconsDir, translate, None,
                                 baseDir, session, wfRequest,
                                 personCache,
                                 nickname, domain, port,
                                 postJsonObject,
                                 None, True, allowDeletion,
                                 httpPrefix, projectVersion,
                                 'search',
                                 YTReplacementDomain,
                                 showIndividualPostIcons,
                                 showIndividualPostIcons,
                                 False, False, False)
        index += 1

    historySearchForm += htmlFooter()
    return historySearchForm


def scheduledPostsExist(baseDir: str, nickname: str, domain: str) -> bool:
    """Returns true if there are posts scheduled to be delivered
    """
    scheduleIndexFilename = \
        baseDir + '/accounts/' + nickname + '@' + domain + '/schedule.index'
    if not os.path.isfile(scheduleIndexFilename):
        return False
    if '#users#' in open(scheduleIndexFilename).read():
        return True
    return False


def htmlEditProfile(translate: {}, baseDir: str, path: str,
                    domain: str, port: int, httpPrefix: str) -> str:
    """Shows the edit profile screen
    """
    imageFormats = '.png, .jpg, .jpeg, .gif, .webp'
    pathOriginal = path
    path = path.replace('/inbox', '').replace('/outbox', '')
    path = path.replace('/shares', '')
    nickname = getNicknameFromActor(path)
    if not nickname:
        return ''
    domainFull = domain
    if port:
        if port != 80 and port != 443:
            if ':' not in domain:
                domainFull = domain + ':' + str(port)

    actorFilename = \
        baseDir + '/accounts/' + nickname + '@' + domain + '.json'
    if not os.path.isfile(actorFilename):
        return ''

    isBot = ''
    isGroup = ''
    followDMs = ''
    removeTwitter = ''
    notifyLikes = ''
    hideLikeButton = ''
    mediaInstanceStr = ''
    displayNickname = nickname
    bioStr = ''
    donateUrl = ''
    emailAddress = ''
    PGPpubKey = ''
    PGPfingerprint = ''
    xmppAddress = ''
    matrixAddress = ''
    ssbAddress = ''
    blogAddress = ''
    toxAddress = ''
    manuallyApprovesFollowers = ''
    actorJson = loadJson(actorFilename)
    if actorJson:
        donateUrl = getDonationUrl(actorJson)
        xmppAddress = getXmppAddress(actorJson)
        matrixAddress = getMatrixAddress(actorJson)
        ssbAddress = getSSBAddress(actorJson)
        blogAddress = getBlogAddress(actorJson)
        toxAddress = getToxAddress(actorJson)
        emailAddress = getEmailAddress(actorJson)
        PGPpubKey = getPGPpubKey(actorJson)
        PGPfingerprint = getPGPfingerprint(actorJson)
        if actorJson.get('name'):
            displayNickname = actorJson['name']
        if actorJson.get('summary'):
            bioStr = \
                actorJson['summary'].replace('<p>', '').replace('</p>', '')
        if actorJson.get('manuallyApprovesFollowers'):
            if actorJson['manuallyApprovesFollowers']:
                manuallyApprovesFollowers = 'checked'
            else:
                manuallyApprovesFollowers = ''
        if actorJson.get('type'):
            if actorJson['type'] == 'Service':
                isBot = 'checked'
                isGroup = ''
            elif actorJson['type'] == 'Group':
                isGroup = 'checked'
                isBot = ''
    if os.path.isfile(baseDir + '/accounts/' +
                      nickname + '@' + domain + '/.followDMs'):
        followDMs = 'checked'
    if os.path.isfile(baseDir + '/accounts/' +
                      nickname + '@' + domain + '/.removeTwitter'):
        removeTwitter = 'checked'
    if os.path.isfile(baseDir + '/accounts/' +
                      nickname + '@' + domain + '/.notifyLikes'):
        notifyLikes = 'checked'
    if os.path.isfile(baseDir + '/accounts/' +
                      nickname + '@' + domain + '/.hideLikeButton'):
        hideLikeButton = 'checked'

    mediaInstance = getConfigParam(baseDir, "mediaInstance")
    if mediaInstance:
        if mediaInstance is True:
            mediaInstanceStr = 'checked'

    filterStr = ''
    filterFilename = \
        baseDir + '/accounts/' + nickname + '@' + domain + '/filters.txt'
    if os.path.isfile(filterFilename):
        with open(filterFilename, 'r') as filterfile:
            filterStr = filterfile.read()

    switchStr = ''
    switchFilename = \
        baseDir + '/accounts/' + \
        nickname + '@' + domain + '/replacewords.txt'
    if os.path.isfile(switchFilename):
        with open(switchFilename, 'r') as switchfile:
            switchStr = switchfile.read()

    blockedStr = ''
    blockedFilename = \
        baseDir + '/accounts/' + \
        nickname + '@' + domain + '/blocking.txt'
    if os.path.isfile(blockedFilename):
        with open(blockedFilename, 'r') as blockedfile:
            blockedStr = blockedfile.read()

    allowedInstancesStr = ''
    allowedInstancesFilename = \
        baseDir + '/accounts/' + \
        nickname + '@' + domain + '/allowedinstances.txt'
    if os.path.isfile(allowedInstancesFilename):
        with open(allowedInstancesFilename, 'r') as allowedInstancesFile:
            allowedInstancesStr = allowedInstancesFile.read()

    gitProjectsStr = ''
    gitProjectsFilename = \
        baseDir + '/accounts/' + \
        nickname + '@' + domain + '/gitprojects.txt'
    if os.path.isfile(gitProjectsFilename):
        with open(gitProjectsFilename, 'r') as gitProjectsFile:
            gitProjectsStr = gitProjectsFile.read()

    skills = getSkills(baseDir, nickname, domain)
    skillsStr = ''
    skillCtr = 1
    if skills:
        for skillDesc, skillValue in skills.items():
            skillsStr += \
                '<p><input type="text" placeholder="' + translate['Skill'] + \
                ' ' + str(skillCtr) + '" name="skillName' + str(skillCtr) + \
                '" value="' + skillDesc + '" style="width:40%">'
            skillsStr += \
                '<input type="range" min="1" max="100" ' + \
                'class="slider" name="skillValue' + \
                str(skillCtr) + '" value="' + str(skillValue) + '"></p>'
            skillCtr += 1

    skillsStr += \
        '<p><input type="text" placeholder="Skill ' + str(skillCtr) + \
        '" name="skillName' + str(skillCtr) + \
        '" value="" style="width:40%">'
    skillsStr += \
        '<input type="range" min="1" max="100" ' + \
        'class="slider" name="skillValue' + \
        str(skillCtr) + '" value="50"></p>'

    cssFilename = baseDir + '/epicyon-profile.css'
    if os.path.isfile(baseDir + '/epicyon.css'):
        cssFilename = baseDir + '/epicyon.css'
    with open(cssFilename, 'r') as cssFile:
        editProfileCSS = cssFile.read()
        if httpPrefix != 'https':
            editProfileCSS = \
                editProfileCSS.replace('https://', httpPrefix + '://')

    instanceStr = ''
    moderatorsStr = ''
    themesDropdown = ''
    adminNickname = getConfigParam(baseDir, 'admin')
    if path.startswith('/users/' + adminNickname + '/'):
        instanceDescription = \
            getConfigParam(baseDir, 'instanceDescription')
        instanceDescriptionShort = \
            getConfigParam(baseDir, 'instanceDescriptionShort')
        instanceTitle = \
            getConfigParam(baseDir, 'instanceTitle')
        instanceStr = '<div class="container">'
        instanceStr += \
            '  <label class="labels">' + \
            translate['Instance Title'] + '</label>'
        if instanceTitle:
            instanceStr += \
                '  <input type="text" name="instanceTitle" value="' + \
                instanceTitle + '"><br>'
        else:
            instanceStr += \
                '  <input type="text" name="instanceTitle" value=""><br>'
        instanceStr += \
            '  <label class="labels">' + \
            translate['Instance Short Description'] + '</label>'
        if instanceDescriptionShort:
            instanceStr += \
                '  <input type="text" ' + \
                'name="instanceDescriptionShort" value="' + \
                instanceDescriptionShort + '"><br>'
        else:
            instanceStr += \
                '  <input type="text" ' + \
                'name="instanceDescriptionShort" value=""><br>'
        instanceStr += \
            '  <label class="labels">' + \
            translate['Instance Description'] + '</label>'
        if instanceDescription:
            instanceStr += \
                '  <textarea id="message" name="instanceDescription" ' + \
                'style="height:200px">' + \
                instanceDescription + '</textarea>'
        else:
            instanceStr += \
                '  <textarea id="message" name="instanceDescription" ' + \
                'style="height:200px"></textarea>'
        instanceStr += \
            '  <label class="labels">' + \
            translate['Instance Logo'] + '</label>'
        instanceStr += \
            '  <input type="file" id="instanceLogo" name="instanceLogo"'
        instanceStr += '      accept="' + imageFormats + '">'
        instanceStr += '</div>'

        moderators = ''
        moderatorsFile = baseDir + '/accounts/moderators.txt'
        if os.path.isfile(moderatorsFile):
            with open(moderatorsFile, "r") as f:
                moderators = f.read()
        moderatorsStr = '<div class="container">'
        moderatorsStr += '  <b>' + translate['Moderators'] + '</b><br>'
        moderatorsStr += '  ' + \
            translate['A list of moderator nicknames. One per line.']
        moderatorsStr += \
            '  <textarea id="message" name="moderators" placeholder="' + \
            translate['List of moderator nicknames'] + \
            '..." style="height:200px">' + moderators + '</textarea>'
        moderatorsStr += '</div>'

        themes = getThemesList()
        themesDropdown = '<div class="container">'
        themesDropdown += '  <b>' + translate['Theme'] + '</b><br>'
        grayscaleFilename = \
            baseDir + '/accounts/.grayscale'
        grayscale = ''
        if os.path.isfile(grayscaleFilename):
            grayscale = 'checked'
        themesDropdown += \
            '      <input type="checkbox" class="profilecheckbox" ' + \
            'name="grayscale" ' + grayscale + \
            '> ' + translate['Grayscale'] + '<br>'
        themesDropdown += '  <select id="themeDropdown" ' + \
            'name="themeDropdown" class="theme">'
        for themeName in themes:
            themesDropdown += '    <option value="' + \
                themeName.lower() + '">' + \
                translate[themeName] + '</option>'
        themesDropdown += '  </select><br>'
        if os.path.isfile(baseDir + '/fonts/custom.woff') or \
           os.path.isfile(baseDir + '/fonts/custom.woff2') or \
           os.path.isfile(baseDir + '/fonts/custom.otf') or \
           os.path.isfile(baseDir + '/fonts/custom.ttf'):
            themesDropdown += \
                '      <input type="checkbox" class="profilecheckbox" ' + \
                'name="removeCustomFont"> ' + \
                translate['Remove the custom font'] + '<br>'
        themesDropdown += '</div>'
        themeName = getConfigParam(baseDir, 'theme')
        themesDropdown = \
            themesDropdown.replace('<option value="' + themeName + '">',
                                   '<option value="' + themeName +
                                   '" selected>')

    editProfileForm = htmlHeader(cssFilename, editProfileCSS)
    editProfileForm += \
        '<form enctype="multipart/form-data" method="POST" ' + \
        'accept-charset="UTF-8" action="' + path + '/profiledata">\n'
    editProfileForm += '  <div class="vertical-center">\n'
    editProfileForm += \
        '    <p class="new-post-text">' + translate['Profile for'] + \
        ' ' + nickname + '@' + domainFull + '</p>'
    editProfileForm += '    <div class="container">\n'
    editProfileForm += \
        '      <a href="' + pathOriginal + '"><button class="cancelbtn">' + \
        translate['Go Back'] + '</button></a>\n'
    editProfileForm += \
        '      <input type="submit" name="submitProfile" value="' + \
        translate['Submit'] + '">\n'
    editProfileForm += '    </div>\n'

    if scheduledPostsExist(baseDir, nickname, domain):
        editProfileForm += '    <div class="container">\n'
        editProfileForm += \
            '      <input type="checkbox" class="profilecheckbox" ' + \
            'name="removeScheduledPosts"> ' + \
            translate['Remove scheduled posts'] + '<br>\n'
        editProfileForm += '    </div>\n'

    editProfileForm += '    <div class="container">\n'
    editProfileForm += '      <label class="labels">' + \
        translate['Nickname'] + '</label>\n'
    editProfileForm += \
        '      <input type="text" name="displayNickname" value="' + \
        displayNickname + '"><br>\n'
    editProfileForm += \
        '      <label class="labels">' + translate['Your bio'] + '</label>\n'
    editProfileForm += \
        '      <textarea id="message" name="bio" style="height:200px">' + \
        bioStr + '</textarea>\n'
    editProfileForm += '<label class="labels">' + \
        translate['Donations link'] + '</label><br>\n'
    editProfileForm += \
        '      <input type="text" placeholder="https://..." ' + \
        'name="donateUrl" value="' + donateUrl + '">\n'
    editProfileForm += \
        '<label class="labels">' + translate['XMPP'] + '</label><br>\n'
    editProfileForm += \
        '      <input type="text" name="xmppAddress" value="' + \
        xmppAddress + '">\n'
    editProfileForm += '<label class="labels">' + \
        translate['Matrix'] + '</label><br>\n'
    editProfileForm += \
        '      <input type="text" name="matrixAddress" value="' + \
        matrixAddress+'">\n'

    editProfileForm += '<label class="labels">SSB</label><br>\n'
    editProfileForm += \
        '      <input type="text" name="ssbAddress" value="' + \
        ssbAddress + '">\n'

    editProfileForm += '<label class="labels">Blog</label><br>\n'
    editProfileForm += \
        '      <input type="text" name="blogAddress" value="' + \
        blogAddress + '">\n'

    editProfileForm += '<label class="labels">Tox</label><br>\n'
    editProfileForm += \
        '      <input type="text" name="toxAddress" value="' + \
        toxAddress + '">\n'
    editProfileForm += '<label class="labels">' + \
        translate['Email'] + '</label><br>\n'
    editProfileForm += \
        '      <input type="text" name="email" value="' + emailAddress + '">\n'
    editProfileForm += \
        '<label class="labels">' + \
        translate['PGP Fingerprint'] + '</label><br>\n'
    editProfileForm += \
        '      <input type="text" name="openpgp" value="' + \
        PGPfingerprint + '">\n'
    editProfileForm += \
        '<label class="labels">' + translate['PGP'] + '</label><br>\n'
    editProfileForm += \
        '      <textarea id="message" placeholder=' + \
        '"-----BEGIN PGP PUBLIC KEY BLOCK-----" name="pgp" ' + \
        'style="height:100px">' + PGPpubKey + '</textarea>\n'
    editProfileForm += '<a href="/users/' + nickname + \
        '/followingaccounts"><label class="labels">' + \
        translate['Following'] + '</label></a><br>\n'
    editProfileForm += '    </div>\n'
    editProfileForm += '    <div class="container">\n'
    idx = 'The files attached below should be no larger than ' + \
        '10MB in total uploaded at once.'
    editProfileForm += \
        '      <label class="labels">' + translate[idx] + '</label><br><br>\n'
    editProfileForm += \
        '      <label class="labels">' + translate['Avatar image'] + \
        '</label>\n'
    editProfileForm += \
        '      <input type="file" id="avatar" name="avatar"'
    editProfileForm += '            accept="' + imageFormats + '">\n'

    editProfileForm += \
        '      <br><label class="labels">' + \
        translate['Background image'] + '</label>\n'
    editProfileForm += '      <input type="file" id="image" name="image"'
    editProfileForm += '            accept="' + imageFormats + '">\n'

    editProfileForm += '      <br><label class="labels">' + \
        translate['Timeline banner image'] + '</label>\n'
    editProfileForm += '      <input type="file" id="banner" name="banner"'
    editProfileForm += '            accept="' + imageFormats + '">\n'

    editProfileForm += '      <br><label class="labels">' + \
        translate['Search banner image'] + '</label>\n'
    editProfileForm += '      <input type="file" id="search_banner" '
    editProfileForm += 'name="search_banner"'
    editProfileForm += '            accept="' + imageFormats + '">\n'

    editProfileForm += '    </div>\n'
    editProfileForm += '    <div class="container">\n'
    editProfileForm += \
        '<label class="labels">' + translate['Change Password'] + \
        '</label><br>\n'
    editProfileForm += '      <input type="text" name="password" ' + \
        'value=""><br>\n'
    editProfileForm += \
        '<label class="labels">' + translate['Confirm Password'] + \
        '</label><br>\n'
    editProfileForm += \
        '      <input type="text" name="passwordconfirm" value="">\n'
    editProfileForm += '    </div>\n'
    editProfileForm += '    <div class="container">\n'
    editProfileForm += \
        '      <input type="checkbox" class="profilecheckbox" ' + \
        'name="approveFollowers" ' + manuallyApprovesFollowers + \
        '> ' + translate['Approve follower requests'] + '<br>\n'
    editProfileForm += \
        '      <input type="checkbox" ' + \
        'class="profilecheckbox" name="isBot" ' + \
        isBot + '> ' + translate['This is a bot account'] + '<br>\n'
    editProfileForm += \
        '      <input type="checkbox" ' + \
        'class="profilecheckbox" name="isGroup" ' + isGroup + '> ' + \
        translate['This is a group account'] + '<br>\n'
    editProfileForm += \
        '      <input type="checkbox" class="profilecheckbox" ' + \
        'name="followDMs" ' + followDMs + '> ' + \
        translate['Only people I follow can send me DMs'] + '<br>\n'
    editProfileForm += \
        '      <input type="checkbox" class="profilecheckbox" ' + \
        'name="removeTwitter" ' + removeTwitter + '> ' + \
        translate['Remove Twitter posts'] + '<br>\n'
    if path.startswith('/users/' + adminNickname + '/'):
        editProfileForm += \
            '      <input type="checkbox" class="profilecheckbox" ' + \
            'name="mediaInstance" ' + mediaInstanceStr + '> ' + \
            translate['This is a media instance'] + '<br>\n'
    editProfileForm += \
        '      <input type="checkbox" class="profilecheckbox" ' + \
        'name="notifyLikes" ' + notifyLikes + '> ' + \
        translate['Notify when posts are liked'] + '<br>\n'
    editProfileForm += \
        '      <input type="checkbox" class="profilecheckbox" ' + \
        'name="hideLikeButton" ' + hideLikeButton + '> ' + \
        translate["Don't show the Like button"] + '<br>\n'

    editProfileForm += \
        '      <br><b><label class="labels">' + \
        translate['Filtered words'] + '</label></b>\n'
    editProfileForm += '      <br><label class="labels">' + \
        translate['One per line'] + '</label>\n'
    editProfileForm += '      <textarea id="message" ' + \
        'name="filteredWords" style="height:200px">' + \
        filterStr + '</textarea>\n'

    editProfileForm += \
        '      <br><b><label class="labels">' + \
        translate['Word Replacements'] + '</label></b>\n'
    editProfileForm += '      <br><label class="labels">A -> B</label>\n'
    editProfileForm += \
        '      <textarea id="message" name="switchWords" ' + \
        'style="height:200px">' + switchStr + '</textarea>\n'

    editProfileForm += \
        '      <br><b><label class="labels">' + \
        translate['Blocked accounts'] + '</label></b>\n'
    idx = 'Blocked accounts, one per line, in the form ' + \
        'nickname@domain or *@blockeddomain'
    editProfileForm += \
        '      <br><label class="labels">' + translate[idx] + '</label>\n'
    editProfileForm += \
        '      <textarea id="message" name="blocked" style="height:200px">' + \
        blockedStr + '</textarea>\n'

    editProfileForm += \
        '      <br><b><label class="labels">' + \
        translate['Federation list'] + '</label></b>\n'
    idx = 'Federate only with a defined set of instances. ' + \
        'One domain name per line.'
    editProfileForm += \
        '      <br><label class="labels">' + \
        translate[idx] + '</label>\n'
    editProfileForm += \
        '      <textarea id="message" name="allowedInstances" ' + \
        'style="height:200px">' + allowedInstancesStr + '</textarea>\n'

    editProfileForm += \
        '      <br><b><label class="labels">' + \
        translate['Git Projects'] + '</label></b>\n'
    idx = 'List of project names that you wish to receive git patches for'
    editProfileForm += \
        '      <br><label class="labels">' + \
        translate[idx] + '</label>\n'
    editProfileForm += \
        '      <textarea id="message" name="gitProjects" ' + \
        'style="height:100px">' + gitProjectsStr + '</textarea>\n'

    editProfileForm += \
        '      <br><b><label class="labels">' + \
        translate['YouTube Replacement Domain'] + '</label></b>\n'
    YTReplacementDomain = getConfigParam(baseDir, "youtubedomain")
    if not YTReplacementDomain:
        YTReplacementDomain = ''
    editProfileForm += \
        '      <input type="text" name="ytdomain" value="' + \
        YTReplacementDomain + '">\n'

    editProfileForm += '    </div>\n'
    editProfileForm += '    <div class="container">\n'
    editProfileForm += \
        '      <b><label class="labels">' + \
        translate['Skills'] + '</label></b><br>\n'
    idx = 'If you want to participate within organizations then you ' + \
        'can indicate some skills that you have and approximate ' + \
        'proficiency levels. This helps organizers to construct ' + \
        'teams with an appropriate combination of skills.'
    editProfileForm += '      <label class="labels">' + \
        translate[idx] + '</label>\n'
    editProfileForm += skillsStr + themesDropdown + moderatorsStr
    editProfileForm += '    </div>\n' + instanceStr
    editProfileForm += '    <div class="container">\n'
    editProfileForm += '      <b><label class="labels">' + \
        translate['Danger Zone'] + '</label></b><br>\n'
    editProfileForm += \
        '      <input type="checkbox" class=dangercheckbox" ' + \
        'name="deactivateThisAccount"> ' + \
        translate['Deactivate this account'] + '<br>\n'
    editProfileForm += '    </div>\n'
    editProfileForm += '  </div>\n'
    editProfileForm += '</form>\n'
    editProfileForm += htmlFooter()
    return editProfileForm


def htmlGetLoginCredentials(loginParams: str,
                            lastLoginTime: int) -> (str, str, bool):
    """Receives login credentials via HTTPServer POST
    """
    if not loginParams.startswith('username='):
        return None, None, None
    # minimum time between login attempts
    currTime = int(time.time())
    if currTime < lastLoginTime+10:
        return None, None, None
    if '&' not in loginParams:
        return None, None, None
    loginArgs = loginParams.split('&')
    nickname = None
    password = None
    register = False
    for arg in loginArgs:
        if '=' in arg:
            if arg.split('=', 1)[0] == 'username':
                nickname = arg.split('=', 1)[1]
            elif arg.split('=', 1)[0] == 'password':
                password = arg.split('=', 1)[1]
            elif arg.split('=', 1)[0] == 'register':
                register = True
    return nickname, password, register


def htmlLogin(translate: {}, baseDir: str, autocomplete=True) -> str:
    """Shows the login screen
    """
    accounts = noOfAccounts(baseDir)

    loginImage = 'login.png'
    loginImageFilename = None
    if os.path.isfile(baseDir + '/accounts/' + loginImage):
        loginImageFilename = baseDir + '/accounts/' + loginImage
    elif os.path.isfile(baseDir + '/accounts/login.jpg'):
        loginImage = 'login.jpg'
        loginImageFilename = baseDir + '/accounts/' + loginImage
    elif os.path.isfile(baseDir + '/accounts/login.jpeg'):
        loginImage = 'login.jpeg'
        loginImageFilename = baseDir + '/accounts/' + loginImage
    elif os.path.isfile(baseDir + '/accounts/login.gif'):
        loginImage = 'login.gif'
        loginImageFilename = baseDir + '/accounts/' + loginImage
    elif os.path.isfile(baseDir + '/accounts/login.webp'):
        loginImage = 'login.webp'
        loginImageFilename = baseDir + '/accounts/' + loginImage

    if not loginImageFilename:
        loginImageFilename = baseDir + '/accounts/' + loginImage
        copyfile(baseDir + '/img/login.png', loginImageFilename)

    if os.path.isfile(baseDir + '/accounts/login-background-custom.jpg'):
        if not os.path.isfile(baseDir + '/accounts/login-background.jpg'):
            copyfile(baseDir + '/accounts/login-background-custom.jpg',
                     baseDir + '/accounts/login-background.jpg')

    if accounts > 0:
        loginText = \
            '<p class="login-text">' + \
            translate['Welcome. Please enter your login details below.'] + \
            '</p>'
    else:
        loginText = \
            '<p class="login-text">' + \
            translate['Please enter some credentials'] + '</p>'
        loginText += \
            '<p class="login-text">' + \
            translate['You will become the admin of this site.'] + \
            '</p>'
    if os.path.isfile(baseDir + '/accounts/login.txt'):
        # custom login message
        with open(baseDir + '/accounts/login.txt', 'r') as file:
            loginText = '<p class="login-text">' + file.read() + '</p>'

    cssFilename = baseDir + '/epicyon-login.css'
    if os.path.isfile(baseDir + '/login.css'):
        cssFilename = baseDir + '/login.css'
    with open(cssFilename, 'r') as cssFile:
        loginCSS = cssFile.read()

    # show the register button
    registerButtonStr = ''
    if getConfigParam(baseDir, 'registration') == 'open':
        if int(getConfigParam(baseDir, 'registrationsRemaining')) > 0:
            if accounts > 0:
                idx = 'Welcome. Please login or register a new account.'
                loginText = \
                    '<p class="login-text">' + \
                    translate[idx] + \
                    '</p>'
            registerButtonStr = \
                '<button type="submit" name="register">Register</button>'

    TOSstr = \
        '<p class="login-text"><a href="/terms">' + \
        translate['Terms of Service'] + '</a></p>'
    TOSstr += \
        '<p class="login-text"><a href="/about">' + \
        translate['About this Instance'] + '</a></p>'

    loginButtonStr = ''
    if accounts > 0:
        loginButtonStr = \
            '<button type="submit" name="submit">' + \
            translate['Login'] + '</button>'

    autocompleteStr = ''
    if not autocomplete:
        autocompleteStr = 'autocomplete="off" value=""'

    loginForm = htmlHeader(cssFilename, loginCSS)
    loginForm += '<br>\n'
    loginForm += '<form method="POST" action="/login">\n'
    loginForm += '  <div class="imgcontainer">\n'
    loginForm += \
        '    <img loading="lazy" src="' + loginImage + \
        '" alt="login image" class="loginimage">\n'
    loginForm += loginText + TOSstr + '\n'
    loginForm += '  </div>\n'
    loginForm += '\n'
    loginForm += '  <div class="container">\n'
    loginForm += '    <label for="nickname"><b>' + \
        translate['Nickname'] + '</b></label>\n'
    loginForm += \
        '    <input type="text" ' + autocompleteStr + ' placeholder="' + \
        translate['Enter Nickname'] + '" name="username" required autofocus>\n'
    loginForm += '\n'
    loginForm += '    <label for="password"><b>' + \
        translate['Password'] + '</b></label>\n'
    loginForm += \
        '    <input type="password" ' + autocompleteStr + \
        ' placeholder="' + translate['Enter Password'] + \
        '" name="password" required>\n'
    loginForm += loginButtonStr + registerButtonStr + '\n'
    loginForm += '  </div>\n'
    loginForm += '</form>\n'
    loginForm += \
        '<a href="https://gitlab.com/bashrc2/epicyon">' + \
        '<img loading="lazy" class="license" title="' + \
        translate['Get the source code'] + '" alt="' + \
        translate['Get the source code'] + '" src="/icons/agpl.png" /></a>\n'
    loginForm += htmlFooter()
    return loginForm


def htmlTermsOfService(baseDir: str, httpPrefix: str, domainFull: str) -> str:
    """Show the terms of service screen
    """
    adminNickname = getConfigParam(baseDir, 'admin')
    if not os.path.isfile(baseDir + '/accounts/tos.txt'):
        copyfile(baseDir + '/default_tos.txt',
                 baseDir + '/accounts/tos.txt')

    if os.path.isfile(baseDir + '/accounts/login-background-custom.jpg'):
        if not os.path.isfile(baseDir + '/accounts/login-background.jpg'):
            copyfile(baseDir + '/accounts/login-background-custom.jpg',
                     baseDir + '/accounts/login-background.jpg')

    TOSText = 'Terms of Service go here.'
    if os.path.isfile(baseDir + '/accounts/tos.txt'):
        with open(baseDir + '/accounts/tos.txt', 'r') as file:
            TOSText = file.read()

    TOSForm = ''
    cssFilename = baseDir + '/epicyon-profile.css'
    if os.path.isfile(baseDir + '/epicyon.css'):
        cssFilename = baseDir + '/epicyon.css'
    with open(cssFilename, 'r') as cssFile:
        termsCSS = cssFile.read()
        if httpPrefix != 'https':
            termsCSS = termsCSS.replace('https://', httpPrefix+'://')

        TOSForm = htmlHeader(cssFilename, termsCSS)
        TOSForm += '<div class="container">' + TOSText + '</div>\n'
        if adminNickname:
            adminActor = httpPrefix + '://' + domainFull + \
                '/users/' + adminNickname
            TOSForm += \
                '<div class="container"><center>\n' + \
                '<p class="administeredby">Administered by <a href="' + \
                adminActor + '">' + adminNickname + '</a></p>\n' + \
                '</center></div>\n'
        TOSForm += htmlFooter()
    return TOSForm


def htmlAbout(baseDir: str, httpPrefix: str,
              domainFull: str, onionDomain: str) -> str:
    """Show the about screen
    """
    adminNickname = getConfigParam(baseDir, 'admin')
    if not os.path.isfile(baseDir + '/accounts/about.txt'):
        copyfile(baseDir + '/default_about.txt',
                 baseDir + '/accounts/about.txt')

    if os.path.isfile(baseDir + '/accounts/login-background-custom.jpg'):
        if not os.path.isfile(baseDir + '/accounts/login-background.jpg'):
            copyfile(baseDir + '/accounts/login-background-custom.jpg',
                     baseDir + '/accounts/login-background.jpg')

    aboutText = 'Information about this instance goes here.'
    if os.path.isfile(baseDir + '/accounts/about.txt'):
        with open(baseDir + '/accounts/about.txt', 'r') as aboutFile:
            aboutText = aboutFile.read()

    aboutForm = ''
    cssFilename = baseDir + '/epicyon-profile.css'
    if os.path.isfile(baseDir + '/epicyon.css'):
        cssFilename = baseDir + '/epicyon.css'
    with open(cssFilename, 'r') as cssFile:
        aboutCSS = cssFile.read()
        if httpPrefix != 'http':
            aboutCSS = aboutCSS.replace('https://',
                                        httpPrefix + '://')

        aboutForm = htmlHeader(cssFilename, aboutCSS)
        aboutForm += '<div class="container">' + aboutText + '</div>'
        if onionDomain:
            aboutForm += \
                '<div class="container"><center>\n' + \
                '<p class="administeredby">' + \
                'http://' + onionDomain + '</p>\n</center></div>\n'
        if adminNickname:
            adminActor = '/users/' + adminNickname
            aboutForm += \
                '<div class="container"><center>\n' + \
                '<p class="administeredby">Administered by <a href="' + \
                adminActor + '">' + adminNickname + '</a></p>\n' + \
                '</center></div>\n'
        aboutForm += htmlFooter()
    return aboutForm


def htmlHashtagBlocked(baseDir: str) -> str:
    """Show the screen for a blocked hashtag
    """
    blockedHashtagForm = ''
    cssFilename = baseDir + '/epicyon-suspended.css'
    if os.path.isfile(baseDir + '/suspended.css'):
        cssFilename = baseDir + '/suspended.css'
    with open(cssFilename, 'r') as cssFile:
        blockedHashtagCSS = cssFile.read()
        blockedHashtagForm = htmlHeader(cssFilename, blockedHashtagCSS)
        blockedHashtagForm += '<div><center>\n'
        blockedHashtagForm += '  <p class="screentitle">Hashtag Blocked</p>\n'
        blockedHashtagForm += \
            '  <p>See <a href="/terms">Terms of Service</a></p>\n'
        blockedHashtagForm += '</center></div>\n'
        blockedHashtagForm += htmlFooter()
    return blockedHashtagForm


def htmlSuspended(baseDir: str) -> str:
    """Show the screen for suspended accounts
    """
    suspendedForm = ''
    cssFilename = baseDir + '/epicyon-suspended.css'
    if os.path.isfile(baseDir + '/suspended.css'):
        cssFilename = baseDir + '/suspended.css'
    with open(cssFilename, 'r') as cssFile:
        suspendedCSS = cssFile.read()
        suspendedForm = htmlHeader(cssFilename, suspendedCSS)
        suspendedForm += '<div><center>\n'
        suspendedForm += '  <p class="screentitle">Account Suspended</p>\n'
        suspendedForm += '  <p>See <a href="/terms">Terms of Service</a></p>\n'
        suspendedForm += '</center></div>\n'
        suspendedForm += htmlFooter()
    return suspendedForm


def htmlNewPost(mediaInstance: bool, translate: {},
                baseDir: str, httpPrefix: str,
                path: str, inReplyTo: str,
                mentions: [],
                reportUrl: str, pageNumber: int,
                nickname: str, domain: str,
                domainFull: str) -> str:
    """New post screen
    """
    iconsDir = getIconsDir(baseDir)
    replyStr = ''

    showPublicOnDropdown = True
    messageBoxHeight = 400

    if not path.endswith('/newshare'):
        if not path.endswith('/newreport'):
            if not inReplyTo or path.endswith('/newreminder'):
                newPostText = '<p class="new-post-text">' + \
                    translate['Write your post text below.'] + '</p>\n'
            else:
                newPostText = \
                    '<p class="new-post-text">' + \
                    translate['Write your reply to'] + \
                    ' <a href="' + inReplyTo + '">' + \
                    translate['this post'] + '</a></p>\n'
                replyStr = '<input type="hidden" ' + \
                    'name="replyTo" value="' + inReplyTo + '">'

                # if replying to a non-public post then also make
                # this post non-public
                if not isPublicPostFromUrl(baseDir, nickname, domain,
                                           inReplyTo):
                    newPostPath = path
                    if '?' in newPostPath:
                        newPostPath = newPostPath.split('?')[0]
                    if newPostPath.endswith('/newpost'):
                        path = path.replace('/newpost', '/newfollowers')
                    elif newPostPath.endswith('/newunlisted'):
                        path = path.replace('/newunlisted', '/newfollowers')
                    showPublicOnDropdown = False
        else:
            newPostText = \
                '<p class="new-post-text">' + \
                translate['Write your report below.'] + '</p>\n'

            # custom report header with any additional instructions
            if os.path.isfile(baseDir + '/accounts/report.txt'):
                with open(baseDir + '/accounts/report.txt', 'r') as file:
                    customReportText = file.read()
                    if '</p>' not in customReportText:
                        customReportText = \
                            '<p class="login-subtext">' + \
                            customReportText + '</p>\n'
                        repStr = '<p class="login-subtext">'
                        customReportText = \
                            customReportText.replace('<p>', repStr)
                        newPostText += customReportText

            idx = 'This message only goes to moderators, even if it ' + \
                'mentions other fediverse addresses.'
            newPostText += \
                '<p class="new-post-subtext">' + translate[idx] + '</p>\n' + \
                '<p class="new-post-subtext">' + translate['Also see'] + \
                ' <a href="/terms">' + \
                translate['Terms of Service'] + '</a></p>\n'
    else:
        newPostText = \
            '<p class="new-post-text">' + \
            translate['Enter the details for your shared item below.'] + \
            '</p>\n'

    if path.endswith('/newquestion'):
        newPostText = \
            '<p class="new-post-text">' + \
            translate['Enter the choices for your question below.'] + \
            '</p>\n'

    if os.path.isfile(baseDir + '/accounts/newpost.txt'):
        with open(baseDir + '/accounts/newpost.txt', 'r') as file:
            newPostText = \
                '<p class="new-post-text">' + file.read() + '</p>\n'

    cssFilename = baseDir + '/epicyon-profile.css'
    if os.path.isfile(baseDir + '/epicyon.css'):
        cssFilename = baseDir + '/epicyon.css'
    with open(cssFilename, 'r') as cssFile:
        newPostCSS = cssFile.read()
        if httpPrefix != 'https':
            newPostCSS = newPostCSS.replace('https://',
                                            httpPrefix + '://')

    if '?' in path:
        path = path.split('?')[0]
    pathBase = path.replace('/newreport', '').replace('/newpost', '')
    pathBase = pathBase.replace('/newblog', '').replace('/newshare', '')
    pathBase = pathBase.replace('/newunlisted', '')
    pathBase = pathBase.replace('/newevent', '')
    pathBase = pathBase.replace('/newreminder', '')
    pathBase = pathBase.replace('/newfollowers', '').replace('/newdm', '')

    newPostImageSection = '    <div class="container">'
    if not path.endswith('/newevent'):
        newPostImageSection += \
            '      <label class="labels">' + \
            translate['Image description'] + '</label>\n'
    else:
        newPostImageSection += \
            '      <label class="labels">' + \
            translate['Event banner image description'] + '</label>\n'
    newPostImageSection += \
        '      <input type="text" name="imageDescription">\n'

    if path.endswith('/newevent'):
        newPostImageSection += \
            '      <label class="labels">' + \
            translate['Banner image'] + '</label>\n'
        newPostImageSection += \
            '      <input type="file" id="attachpic" name="attachpic"'
        newPostImageSection += \
            '            accept=".png, .jpg, .jpeg, .gif, .webp">\n'
    else:
        newPostImageSection += \
            '      <input type="file" id="attachpic" name="attachpic"'
        newPostImageSection += \
            '            accept=".png, .jpg, .jpeg, .gif, .webp, .mp4, ' + \
            '.webm, .ogv, .mp3, .ogg">\n'
    newPostImageSection += '    </div>\n'

    scopeIcon = 'scope_public.png'
    scopeDescription = translate['Public']
    placeholderSubject = \
        translate['Subject or Content Warning (optional)'] + '...'
    placeholderMentions = ''
    if inReplyTo:
        # mentionsAndContent = getMentionsString(content)
        placeholderMentions = \
            translate['Replying to'] + '...'
    placeholderMessage = translate['Write something'] + '...'
    extraFields = ''
    endpoint = 'newpost'
    if path.endswith('/newblog'):
        placeholderSubject = translate['Title']
        scopeIcon = 'scope_blog.png'
        scopeDescription = translate['Blog']
        endpoint = 'newblog'
    elif path.endswith('/newunlisted'):
        scopeIcon = 'scope_unlisted.png'
        scopeDescription = translate['Unlisted']
        endpoint = 'newunlisted'
    elif path.endswith('/newfollowers'):
        scopeIcon = 'scope_followers.png'
        scopeDescription = translate['Followers']
        endpoint = 'newfollowers'
    elif path.endswith('/newdm'):
        scopeIcon = 'scope_dm.png'
        scopeDescription = translate['DM']
        endpoint = 'newdm'
    elif path.endswith('/newreminder'):
        scopeIcon = 'scope_reminder.png'
        scopeDescription = translate['Reminder']
        endpoint = 'newreminder'
    elif path.endswith('/newevent'):
        scopeIcon = 'scope_event.png'
        scopeDescription = translate['Event']
        endpoint = 'newevent'
        placeholderSubject = translate['Event name']
        placeholderMessage = translate['Describe the event'] + '...'
    elif path.endswith('/newreport'):
        scopeIcon = 'scope_report.png'
        scopeDescription = translate['Report']
        endpoint = 'newreport'
    elif path.endswith('/newquestion'):
        scopeIcon = 'scope_question.png'
        scopeDescription = translate['Question']
        placeholderMessage = translate['Enter your question'] + '...'
        endpoint = 'newquestion'
        extraFields = '<div class="container">\n'
        extraFields += '  <label class="labels">' + \
            translate['Possible answers'] + ':</label><br>\n'
        for questionCtr in range(8):
            extraFields += \
                '  <input type="text" class="questionOption" placeholder="' + \
                str(questionCtr + 1) + \
                '" name="questionOption' + str(questionCtr) + '"><br>\n'
        extraFields += \
            '  <label class="labels">' + \
            translate['Duration of listing in days'] + \
            ':</label> <input type="number" name="duration" ' + \
            'min="1" max="365" step="1" value="14"><br>\n'
        extraFields += '</div>'
    elif path.endswith('/newshare'):
        scopeIcon = 'scope_share.png'
        scopeDescription = translate['Shared Item']
        placeholderSubject = translate['Name of the shared item'] + '...'
        placeholderMessage = \
            translate['Description of the item being shared'] + '...'
        endpoint = 'newshare'
        extraFields = '<div class="container">\n'
        extraFields += \
            '  <label class="labels">' + \
            translate['Type of shared item. eg. hat'] + ':</label>\n'
        extraFields += \
            '  <input type="text" class="itemType" name="itemType">\n'
        extraFields += \
            '  <br><label class="labels">' + \
            translate['Category of shared item. eg. clothing'] + ':</label>\n'
        extraFields += \
            '  <input type="text" class="category" name="category">\n'
        extraFields += \
            '  <br><label class="labels">' + \
            translate['Duration of listing in days'] + ':</label>\n'
        extraFields += '  <input type="number" name="duration" ' + \
            'min="1" max="365" step="1" value="14">\n'
        extraFields += '</div>\n'
        extraFields += '<div class="container">\n'
        extraFields += \
            '<label class="labels">' + \
            translate['City or location of the shared item'] + ':</label>\n'
        extraFields += '<input type="text" name="location">\n'
        extraFields += '</div>\n'

    dateAndLocation = ''
    if endpoint != 'newshare' and \
       endpoint != 'newreport' and \
       endpoint != 'newquestion':
        dateAndLocation = '<div class="container">\n'

        if endpoint == 'newevent':
            # event status
            dateAndLocation += '<label class="labels">' + \
                translate['Status of the event'] + ':</label><br>\n'
            dateAndLocation += '<input type="radio" id="tentative" ' + \
                'name="eventStatus" value="tentative">\n'
            dateAndLocation += '<label class="labels" for="tentative">' + \
                translate['Tentative'] + '</label><br>\n'
            dateAndLocation += '<input type="radio" id="confirmed" ' + \
                'name="eventStatus" value="confirmed" checked>\n'
            dateAndLocation += '<label class="labels" for="confirmed">' + \
                translate['Confirmed'] + '</label><br>\n'
            dateAndLocation += '<input type="radio" id="cancelled" ' + \
                'name="eventStatus" value="cancelled">\n'
            dateAndLocation += '<label class="labels" for="cancelled">' + \
                translate['Cancelled'] + '</label><br>\n'
            dateAndLocation += '</div>\n'
            dateAndLocation += '<div class="container">\n'
            # maximum attendees
            dateAndLocation += '<label class="labels" ' + \
                'for="maximumAttendeeCapacity">' + \
                translate['Maximum attendees'] + ':</label>\n'
            dateAndLocation += '<input type="number" ' + \
                'id="maximumAttendeeCapacity" ' + \
                'name="maximumAttendeeCapacity" min="1" max="999999" ' + \
                'value="100">\n'
            dateAndLocation += '</div>\n'
            dateAndLocation += '<div class="container">\n'
            # event joining options
            dateAndLocation += '<label class="labels">' + \
                translate['Joining'] + ':</label><br>\n'
            dateAndLocation += '<input type="radio" id="free" ' + \
                'name="joinMode" value="free" checked>\n'
            dateAndLocation += '<label class="labels" for="free">' + \
                translate['Anyone can join'] + '</label><br>\n'
            dateAndLocation += '<input type="radio" id="restricted" ' + \
                'name="joinMode" value="restricted">\n'
            dateAndLocation += '<label class="labels" for="female">' + \
                translate['Apply to join'] + '</label><br>\n'
            dateAndLocation += '<input type="radio" id="invite" ' + \
                'name="joinMode" value="invite">\n'
            dateAndLocation += '<label class="labels" for="other">' + \
                translate['Invitation only'] + '</label>\n'
            dateAndLocation += '</div>\n'
            dateAndLocation += '<div class="container">\n'
            # Event posts don't allow replies - they're just an announcement.
            # They also have a few more checkboxes
            dateAndLocation += \
                '<p><input type="checkbox" class="profilecheckbox" ' + \
                'name="privateEvent"><label class="labels"> ' + \
                translate['This is a private event.'] + '</label></p>\n'
            dateAndLocation += \
                '<p><input type="checkbox" class="profilecheckbox" ' + \
                'name="anonymousParticipationEnabled">' + \
                '<label class="labels"> ' + \
                translate['Allow anonymous participation.'] + '</label></p>\n'
        else:
            dateAndLocation += \
                '<p><input type="checkbox" class="profilecheckbox" ' + \
                'name="commentsEnabled" checked><label class="labels"> ' + \
                translate['Allow replies.'] + '</label></p>\n'

        if not inReplyTo and endpoint != 'newevent':
            dateAndLocation += \
                '<p><input type="checkbox" class="profilecheckbox" ' + \
                'name="schedulePost"><label class="labels"> ' + \
                translate['This is a scheduled post.'] + '</label></p>\n'

        if endpoint != 'newevent':
            dateAndLocation += \
                '<p><img loading="lazy" alt="" title="" ' + \
                'class="emojicalendar" src="/' + \
                iconsDir + '/calendar.png"/>\n'
            # select a date and time for this post
            dateAndLocation += '<label class="labels">' + \
                translate['Date'] + ': </label>\n'
            dateAndLocation += '<input type="date" name="eventDate">\n'
            dateAndLocation += '<label class="labelsright">' + \
                translate['Time'] + ':'
            dateAndLocation += \
                '<input type="time" name="eventTime"></label></p>\n'
        else:
            dateAndLocation += '</div>\n'
            dateAndLocation += '<div class="container">\n'
            dateAndLocation += \
                '<p><img loading="lazy" alt="" title="" ' + \
                'class="emojicalendar" src="/' + \
                iconsDir + '/calendar.png"/>\n'
            # select start time for the event
            dateAndLocation += '<label class="labels">' + \
                translate['Start Date'] + ': </label>\n'
            dateAndLocation += '<input type="date" name="eventDate">\n'
            dateAndLocation += '<label class="labelsright">' + \
                translate['Time'] + ':'
            dateAndLocation += \
                '<input type="time" name="eventTime"></label></p>\n'
            # select end time for the event
            dateAndLocation += \
                '<br><img loading="lazy" alt="" title="" ' + \
                'class="emojicalendar" src="/' + \
                iconsDir + '/calendar.png"/>\n'
            dateAndLocation += '<label class="labels">' + \
                translate['End Date'] + ': </label>\n'
            dateAndLocation += '<input type="date" name="endDate">\n'
            dateAndLocation += '<label class="labelsright">' + \
                translate['Time'] + ':'
            dateAndLocation += \
                '<input type="time" name="endTime"></label>\n'

        if endpoint == 'newevent':
            dateAndLocation += '</div>\n'
            dateAndLocation += '<div class="container">\n'
            dateAndLocation += '<br><label class="labels">' + \
                translate['Moderation policy or code of conduct'] + \
                ': </label>\n'
            dateAndLocation += \
                '    <textarea id="message" ' + \
                'name="repliesModerationOption" style="height:' + \
                str(messageBoxHeight) + 'px"></textarea>\n'
        dateAndLocation += '</div>\n'
        dateAndLocation += '<div class="container">\n'
        dateAndLocation += '<br><label class="labels">' + \
            translate['Location'] + ': </label>\n'
        dateAndLocation += '<input type="text" name="location">\n'
        if endpoint == 'newevent':
            dateAndLocation += '<br><label class="labels">' + \
                translate['Ticket URL'] + ': </label>\n'
            dateAndLocation += '<input type="text" name="ticketUrl">\n'
            dateAndLocation += '<br><label class="labels">' + \
                translate['Categories'] + ': </label>\n'
            dateAndLocation += '<input type="text" name="category">\n'
        dateAndLocation += '</div>\n'

    newPostForm = htmlHeader(cssFilename, newPostCSS)

    # only show the share option if this is not a reply
    shareOptionOnDropdown = ''
    questionOptionOnDropdown = ''
    if not replyStr:
        shareOptionOnDropdown = \
            '        <a href="' + pathBase + \
            '/newshare"><li><img loading="lazy" alt="" title="" src="/' + \
            iconsDir + '/scope_share.png"/><b>' + translate['Shares'] + \
            '</b><br>' + translate['Describe a shared item'] + '</li></a>\n'
        questionOptionOnDropdown = \
            '        <a href="' + pathBase + \
            '/newquestion"><li><img loading="lazy" alt="" title="" src="/' + \
            iconsDir + '/scope_question.png"/><b>' + translate['Question'] + \
            '</b><br>' + translate['Ask a question'] + '</li></a>\n'

    mentionsStr = ''
    for m in mentions:
        mentionNickname = getNicknameFromActor(m)
        if not mentionNickname:
            continue
        mentionDomain, mentionPort = getDomainFromActor(m)
        if not mentionDomain:
            continue
        if mentionPort:
            mentionsHandle = \
                '@' + mentionNickname + '@' + \
                mentionDomain + ':' + str(mentionPort)
        else:
            mentionsHandle = '@' + mentionNickname + '@' + mentionDomain
        if mentionsHandle not in mentionsStr:
            mentionsStr += mentionsHandle + ' '

    # build suffixes so that any replies or mentions are
    # preserved when switching between scopes
    dropdownNewPostSuffix = '/newpost'
    dropdownNewBlogSuffix = '/newblog'
    dropdownUnlistedSuffix = '/newunlisted'
    dropdownFollowersSuffix = '/newfollowers'
    dropdownDMSuffix = '/newdm'
    dropdownEventSuffix = '/newevent'
    dropdownReminderSuffix = '/newreminder'
    dropdownReportSuffix = '/newreport'
    if inReplyTo or mentions:
        dropdownNewPostSuffix = ''
        dropdownNewBlogSuffix = ''
        dropdownUnlistedSuffix = ''
        dropdownFollowersSuffix = ''
        dropdownDMSuffix = ''
        dropdownEventSuffix = ''
        dropdownReminderSuffix = ''
        dropdownReportSuffix = ''
    if inReplyTo:
        dropdownNewPostSuffix += '?replyto=' + inReplyTo
        dropdownNewBlogSuffix += '?replyto=' + inReplyTo
        dropdownUnlistedSuffix += '?replyto=' + inReplyTo
        dropdownFollowersSuffix += '?replyfollowers=' + inReplyTo
        dropdownDMSuffix += '?replydm=' + inReplyTo
    for mentionedActor in mentions:
        dropdownNewPostSuffix += '?mention=' + mentionedActor
        dropdownNewBlogSuffix += '?mention=' + mentionedActor
        dropdownUnlistedSuffix += '?mention=' + mentionedActor
        dropdownFollowersSuffix += '?mention=' + mentionedActor
        dropdownDMSuffix += '?mention=' + mentionedActor
        dropdownReportSuffix += '?mention=' + mentionedActor

    dropDownContent = ''
    if not reportUrl:
        dropDownContent += "<div class='msgscope-collapse collapse "
        dropDownContent += "right desktoponly' id='msgscope'>\n"
        dropDownContent += "  <ul class='nav msgscope-nav msgscope-right'>\n"
        dropDownContent += "  <li class=' ' style='position: relative;'>\n"
        dropDownContent += "  <div class='toggle-msgScope button-msgScope'>\n"
        dropDownContent += "    <input id='toggleMsgScope' "
        dropDownContent += "name='toggleMsgScope' type='checkbox'/>\n"
        dropDownContent += "    <label for='toggleMsgScope'>\n"
        dropDownContent += "      <div class='lined-thin'>\n"
        dropDownContent += '        <img loading="lazy" alt="" title="" src="/'
        dropDownContent += iconsDir + '/' + scopeIcon
        dropDownContent += '"/><b class="scope-desc">'
        dropDownContent += scopeDescription + '</b>\n'
        dropDownContent += "        <span class='caret'/>\n"
        dropDownContent += "      </div>\n"
        dropDownContent += "    </label>\n"
        dropDownContent += "    <div class='toggle-inside'>\n"
        dropDownContent += "      <ul aria-labelledby='dropdownMsgScope' "
        dropDownContent += "class='dropdown-menutoggle'>\n"

        if showPublicOnDropdown:
            dropDownContent += "        " \
                '<a href="' + pathBase + dropdownNewPostSuffix + \
                '"><li><img loading="lazy" alt="" title="" src="/' + \
                iconsDir + '/scope_public.png"/><b>' + \
                translate['Public'] + '</b><br>' + \
                translate['Visible to anyone'] + '</li></a>\n'
            dropDownContent += "        " \
                '<a href="' + pathBase + dropdownNewBlogSuffix + \
                '"><li><img loading="lazy" alt="" title="" src="/' + \
                iconsDir + '/edit.png"/><b>' + \
                translate['Blog'] + '</b><br>' + \
                translate['Publicly visible post'] + '</li></a>\n'
            dropDownContent += "        " \
                '<a href="' + pathBase + dropdownUnlistedSuffix + \
                '"><li><img loading="lazy" alt="" title="" src="/' + \
                iconsDir+'/scope_unlisted.png"/><b>' + \
                translate['Unlisted'] + '</b><br>' + \
                translate['Not on public timeline'] + '</li></a>\n'
        dropDownContent += "        " \
            '<a href="' + pathBase + dropdownFollowersSuffix + \
            '"><li><img loading="lazy" alt="" title="" src="/' + \
            iconsDir + '/scope_followers.png"/><b>' + \
            translate['Followers'] + '</b><br>' + \
            translate['Only to followers'] + '</li></a>\n'
        dropDownContent += "        " \
            '<a href="' + pathBase + dropdownDMSuffix + \
            '"><li><img loading="lazy" alt="" title="" src="/' + \
            iconsDir + '/scope_dm.png"/><b>' + translate['DM'] + \
            '</b><br>' + translate['Only to mentioned people'] + \
            '</li></a>\n'
        dropDownContent += "        " \
            '<a href="' + pathBase + dropdownReminderSuffix + \
            '"><li><img loading="lazy" alt="" title="" src="/' + \
            iconsDir + '/scope_reminder.png"/><b>' + translate['Reminder'] + \
            '</b><br>' + translate['Scheduled note to yourself'] + \
            '</li></a>\n'
        dropDownContent += "        " \
            '<a href="' + pathBase + dropdownEventSuffix + \
            '"><li><img loading="lazy" alt="" title="" src="/' + \
            iconsDir + '/scope_event.png"/><b>' + translate['Event'] + \
            '</b><br>' + translate['Create an event'] + \
            '</li></a>\n'
        dropDownContent += "        " \
            '<a href="' + pathBase + dropdownReportSuffix + \
            '"><li><img loading="lazy" alt="" title="" src="/' + iconsDir + \
            '/scope_report.png"/><b>' + translate['Report'] + \
            '</b><br>' + translate['Send to moderators'] + '</li></a>\n'
        dropDownContent += questionOptionOnDropdown + shareOptionOnDropdown
        dropDownContent += '      </ul>\n'
        dropDownContent += '    </div>\n'
        dropDownContent += '  </div>\n'
        dropDownContent += '  </li>\n'
        dropDownContent += '  </ul>\n'
        dropDownContent += '</div>\n'
    else:
        mentionsStr = 'Re: ' + reportUrl + '\n\n' + mentionsStr

    newPostForm += \
        '<form enctype="multipart/form-data" method="POST" ' + \
        'accept-charset="UTF-8" action="' + \
        path + '?' + endpoint + '?page=' + str(pageNumber) + '">\n'
    newPostForm += '  <div class="vertical-center">\n'
    newPostForm += \
        '    <label for="nickname"><b>' + newPostText + '</b></label>\n'
    newPostForm += '    <div class="container">\n'
    newPostForm += '      <table style="width:100%" border="0"><tr>\n'
    newPostForm += '<td>' + dropDownContent + '</td>\n'
    newPostForm += \
        '      <td><a href="' + pathBase + \
        '/searchemoji"><img loading="lazy" class="emojisearch" ' + \
        'src="/emoji/1F601.png" title="' + \
        translate['Search for emoji'] + '" alt="' + \
        translate['Search for emoji'] + '"/></a></td>\n'
    newPostForm += '      </tr>\n'
    newPostForm += '</table>\n'
    newPostForm += '    </div>\n'
    newPostForm += '    <div class="container"><center>\n'
    newPostForm += \
        '      <a href="' + pathBase + \
        '/inbox"><button class="cancelbtn">' + \
        translate['Go Back'] + '</button></a>\n'
    newPostForm += \
        '      <input type="submit" name="submitPost" value="' + \
        translate['Submit'] + '">\n'
    newPostForm += '    </center></div>\n'
    newPostForm += replyStr
    if mediaInstance and not replyStr:
        newPostForm += newPostImageSection

    newPostForm += \
        '    <label class="labels">' + placeholderSubject + '</label><br>'
    newPostForm += '    <input type="text" name="subject">'
    newPostForm += ''

    selectedStr = ' selected'
    if inReplyTo or endpoint == 'newdm':
        if inReplyTo:
            newPostForm += \
                '    <label class="labels">' + placeholderMentions + \
                '</label><br>\n'
        else:
            newPostForm += \
                '    <a href="/users/' + nickname + \
                '/followingaccounts" title="' + \
                translate['Show a list of addresses to send to'] + '">' \
                '<label class="labels">' + \
                translate['Send to'] + ':' + '</label> 📄</a><br>\n'
        newPostForm += \
            '    <input type="text" name="mentions" ' + \
            'list="followingHandles" value="' + mentionsStr + '" selected>\n'
        newPostForm += \
            htmlFollowingDataList(baseDir, nickname, domain, domainFull)
        newPostForm += ''
        selectedStr = ''

    newPostForm += \
        '    <br><label class="labels">' + placeholderMessage + '</label>'
    if mediaInstance:
        messageBoxHeight = 200

    if endpoint == 'newquestion':
        messageBoxHeight = 100
    elif endpoint == 'newblog':
        messageBoxHeight = 800

    newPostForm += \
        '    <textarea id="message" name="message" style="height:' + \
        str(messageBoxHeight) + 'px"' + selectedStr + '></textarea>\n'
    newPostForm += extraFields+dateAndLocation
    if not mediaInstance or replyStr:
        newPostForm += newPostImageSection
    newPostForm += '  </div>\n'
    newPostForm += '</form>\n'

    if not reportUrl:
        newPostForm = \
            newPostForm.replace('<body>', '<body onload="focusOnMessage()">')

    newPostForm += htmlFooter()
    return newPostForm


def getFontFromCss(css: str) -> (str, str):
    """Returns the font name and format
    """
    if ' url(' not in css:
        return None, None
    fontName = css.split(" url(")[1].split(")")[0].replace("'", '')
    fontFormat = css.split(" format('")[1].split("')")[0]
    return fontName, fontFormat


def htmlHeader(cssFilename: str, css: str, lang='en') -> str:
    htmlStr = '<!DOCTYPE html>\n'
    htmlStr += '<html lang="' + lang + '">\n'
    htmlStr += '  <head>\n'
    htmlStr += '    <meta charset="utf-8">\n'
    fontName, fontFormat = getFontFromCss(css)
    if fontName:
        htmlStr += '    <link rel="preload" as="font" type="' + \
            fontFormat + '" href="' + fontName + '" crossorigin>\n'
    htmlStr += '    <style>\n' + css + '</style>\n'
    htmlStr += '    <link rel="manifest" href="/manifest.json">\n'
    htmlStr += '    <meta name="theme-color" content="grey">\n'
    htmlStr += '  </head>\n'
    htmlStr += '  <body>\n'
    return htmlStr


def htmlFooter() -> str:
    htmlStr = '  </body>\n'
    htmlStr += '</html>\n'
    return htmlStr


def htmlProfilePosts(recentPostsCache: {}, maxRecentPosts: int,
                     translate: {},
                     baseDir: str, httpPrefix: str,
                     authorized: bool, ocapAlways: bool,
                     nickname: str, domain: str, port: int,
                     session, wfRequest: {}, personCache: {},
                     projectVersion: str,
                     YTReplacementDomain: str) -> str:
    """Shows posts on the profile screen
    These should only be public posts
    """
    iconsDir = getIconsDir(baseDir)
    profileStr = ''
    maxItems = 4
    ctr = 0
    currPage = 1
    while ctr < maxItems and currPage < 4:
        outboxFeed = \
            personBoxJson({}, session, baseDir, domain,
                          port,
                          '/users/' + nickname + '/outbox?page=' +
                          str(currPage),
                          httpPrefix,
                          10, 'outbox',
                          authorized,
                          ocapAlways)
        if not outboxFeed:
            break
        if len(outboxFeed['orderedItems']) == 0:
            break
        for item in outboxFeed['orderedItems']:
            if item['type'] == 'Create':
                postStr = \
                    individualPostAsHtml(True, recentPostsCache,
                                         maxRecentPosts,
                                         iconsDir, translate, None,
                                         baseDir, session, wfRequest,
                                         personCache,
                                         nickname, domain, port, item,
                                         None, True, False,
                                         httpPrefix, projectVersion, 'inbox',
                                         YTReplacementDomain,
                                         False, False, False, True, False)
                if postStr:
                    profileStr += postStr
                    ctr += 1
                    if ctr >= maxItems:
                        break
        currPage += 1
    return profileStr


def htmlProfileFollowing(translate: {}, baseDir: str, httpPrefix: str,
                         authorized: bool, ocapAlways: bool,
                         nickname: str, domain: str, port: int,
                         session, wfRequest: {}, personCache: {},
                         followingJson: {}, projectVersion: str,
                         buttons: [],
                         feedName: str, actor: str,
                         pageNumber: int,
                         maxItemsPerPage: int) -> str:
    """Shows following on the profile screen
    """
    profileStr = ''

    iconsDir = getIconsDir(baseDir)
    if authorized and pageNumber:
        if authorized and pageNumber > 1:
            # page up arrow
            profileStr += \
                '<center>\n<a href="' + actor + '/' + feedName + \
                '?page=' + str(pageNumber - 1) + \
                '"><img loading="lazy" class="pageicon" src="/' + \
                iconsDir + '/pageup.png" title="' + \
                translate['Page up'] + '" alt="' + \
                translate['Page up'] + '"></a>\n</center>\n'

    for item in followingJson['orderedItems']:
        profileStr += \
            individualFollowAsHtml(translate, baseDir, session,
                                   wfRequest, personCache,
                                   domain, item, authorized, nickname,
                                   httpPrefix, projectVersion,
                                   buttons)
    if authorized and maxItemsPerPage and pageNumber:
        if len(followingJson['orderedItems']) >= maxItemsPerPage:
            # page down arrow
            profileStr += \
                '<center>\n<a href="' + actor + '/' + feedName + \
                '?page=' + str(pageNumber + 1) + \
                '"><img loading="lazy" class="pageicon" src="/' + \
                iconsDir + '/pagedown.png" title="' + \
                translate['Page down'] + '" alt="' + \
                translate['Page down'] + '"></a>\n</center>\n'
    return profileStr


def htmlProfileRoles(translate: {}, nickname: str, domain: str,
                     rolesJson: {}) -> str:
    """Shows roles on the profile screen
    """
    profileStr = ''
    for project, rolesList in rolesJson.items():
        profileStr += \
            '<div class="roles">\n<h2>' + project + \
            '</h2>\n<div class="roles-inner">\n'
        for role in rolesList:
            profileStr += '<h3>' + role + '</h3>\n'
        profileStr += '</div></div>\n'
    if len(profileStr) == 0:
        profileStr += \
            '<p>@' + nickname + '@' + domain + ' has no roles assigned</p>\n'
    else:
        profileStr = '<div>' + profileStr + '</div>\n'
    return profileStr


def htmlProfileSkills(translate: {}, nickname: str, domain: str,
                      skillsJson: {}) -> str:
    """Shows skills on the profile screen
    """
    profileStr = ''
    for skill, level in skillsJson.items():
        profileStr += \
            '<div>' + skill + \
            '<br><div id="myProgress"><div id="myBar" style="width:' + \
            str(level) + '%"></div></div></div>\n<br>\n'
    if len(profileStr) > 0:
        profileStr = '<center><div class="skill-title">' + \
            profileStr + '</div></center>\n'
    return profileStr


def htmlIndividualShare(actor: str, item: {}, translate: {},
                        showContact: bool, removeButton: bool) -> str:
    """Returns an individual shared item as html
    """
    profileStr = '<div class="container">\n'
    profileStr += '<p class="share-title">' + item['displayName'] + '</p>\n'
    if item.get('imageUrl'):
        profileStr += '<a href="' + item['imageUrl'] + '">\n'
        profileStr += \
            '<img loading="lazy" src="' + item['imageUrl'] + \
            '" alt="' + translate['Item image'] + '">\n</a>\n'
    profileStr += '<p>' + item['summary'] + '</p>\n'
    profileStr += \
        '<p><b>' + translate['Type'] + ':</b> ' + item['itemType'] + ' '
    profileStr += \
        '<b>' + translate['Category'] + ':</b> ' + item['category'] + ' '
    profileStr += \
        '<b>' + translate['Location'] + ':</b> ' + item['location'] + '</p>\n'
    if showContact:
        contactActor = item['actor']
        profileStr += \
            '<p><a href="' + actor + \
            '?replydm=sharedesc:' + item['displayName'] + \
            '?mention=' + contactActor + '"><button class="button">' + \
            translate['Contact'] + '</button></a>\n'
    if removeButton:
        profileStr += \
            ' <a href="' + actor + '?rmshare=' + item['displayName'] + \
            '"><button class="button">' + \
            translate['Remove'] + '</button></a>\n'
    profileStr += '</div>\n'
    return profileStr


def htmlProfileShares(actor: str, translate: {},
                      nickname: str, domain: str, sharesJson: {}) -> str:
    """Shows shares on the profile screen
    """
    profileStr = ''
    for item in sharesJson['orderedItems']:
        profileStr += htmlIndividualShare(actor, item, translate, False, False)
    if len(profileStr) > 0:
        profileStr = '<div class="share-title">' + profileStr + '</div>\n'
    return profileStr


def sharesTimelineJson(actor: str, pageNumber: int, itemsPerPage: int,
                       baseDir: str, maxSharesPerAccount: int) -> ({}, bool):
    """Get a page on the shared items timeline as json
    maxSharesPerAccount helps to avoid one person dominating the timeline
    by sharing a large number of things
    """
    allSharesJson = {}
    for subdir, dirs, files in os.walk(baseDir + '/accounts'):
        for handle in dirs:
            if '@' in handle:
                accountDir = baseDir + '/accounts/' + handle
                sharesFilename = accountDir + '/shares.json'
                if os.path.isfile(sharesFilename):
                    sharesJson = loadJson(sharesFilename)
                    if not sharesJson:
                        continue
                    nickname = handle.split('@')[0]
                    # actor who owns this share
                    owner = actor.split('/users/')[0] + '/users/' + nickname
                    ctr = 0
                    for itemID, item in sharesJson.items():
                        # assign owner to the item
                        item['actor'] = owner
                        allSharesJson[str(item['published'])] = item
                        ctr += 1
                        if ctr >= maxSharesPerAccount:
                            break
    # sort the shared items in descending order of publication date
    sharesJson = OrderedDict(sorted(allSharesJson.items(), reverse=True))
    lastPage = False
    startIndex = itemsPerPage*pageNumber
    maxIndex = len(sharesJson.items())
    if maxIndex < itemsPerPage:
        lastPage = True
    if startIndex >= maxIndex - itemsPerPage:
        lastPage = True
        startIndex = maxIndex - itemsPerPage
        if startIndex < 0:
            startIndex = 0
    ctr = 0
    resultJson = {}
    for published, item in sharesJson.items():
        if ctr >= startIndex + itemsPerPage:
            break
        if ctr < startIndex:
            ctr += 1
            continue
        resultJson[published] = item
        ctr += 1
    return resultJson, lastPage


def htmlSharesTimeline(translate: {}, pageNumber: int, itemsPerPage: int,
                       baseDir: str, actor: str,
                       nickname: str, domain: str, port: int,
                       maxSharesPerAccount: int, httpPrefix: str) -> str:
    """Show shared items timeline as html
    """
    sharesJson, lastPage = \
        sharesTimelineJson(actor, pageNumber, itemsPerPage,
                           baseDir, maxSharesPerAccount)
    domainFull = domain
    if port != 80 and port != 443:
        if ':' not in domain:
            domainFull = domain + ':' + str(port)
    actor = httpPrefix + '://' + domainFull + '/users/' + nickname
    timelineStr = ''

    if pageNumber > 1:
        iconsDir = getIconsDir(baseDir)
        timelineStr += \
            '<center>\n<a href="' + actor + '/tlshares?page=' + \
            str(pageNumber - 1) + \
            '"><img loading="lazy" class="pageicon" src="/' + \
            iconsDir + '/pageup.png" title="' + translate['Page up'] + \
            '" alt="' + translate['Page up'] + '"></a>\n</center>\n'

    for published, item in sharesJson.items():
        showContactButton = False
        if item['actor'] != actor:
            showContactButton = True
        showRemoveButton = False
        if item['actor'] == actor:
            showRemoveButton = True
        timelineStr += \
            htmlIndividualShare(actor, item, translate,
                                showContactButton, showRemoveButton)

    if not lastPage:
        iconsDir = getIconsDir(baseDir)
        timelineStr += \
            '<center>\n<a href="' + actor + '/tlshares?page=' + \
            str(pageNumber + 1) + \
            '"><img loading="lazy" class="pageicon" src="/' + \
            iconsDir + '/pagedown.png" title="' + translate['Page down'] + \
            '" alt="' + translate['Page down'] + '"></a>\n</center>\n'

    return timelineStr


def htmlProfile(defaultTimeline: str,
                recentPostsCache: {}, maxRecentPosts: int,
                translate: {}, projectVersion: str,
                baseDir: str, httpPrefix: str, authorized: bool,
                ocapAlways: bool, profileJson: {}, selected: str,
                session, wfRequest: {}, personCache: {},
                YTReplacementDomain: str,
                extraJson=None,
                pageNumber=None, maxItemsPerPage=None) -> str:
    """Show the profile page as html
    """
    nickname = profileJson['preferredUsername']
    if not nickname:
        return ""
    domain, port = getDomainFromActor(profileJson['id'])
    if not domain:
        return ""
    displayName = \
        addEmojiToDisplayName(baseDir, httpPrefix,
                              nickname, domain,
                              profileJson['name'], True)
    domainFull = domain
    if port:
        domainFull = domain + ':' + str(port)
    profileDescription = \
        addEmojiToDisplayName(baseDir, httpPrefix,
                              nickname, domain,
                              profileJson['summary'], False)
    postsButton = 'button'
    followingButton = 'button'
    followersButton = 'button'
    rolesButton = 'button'
    skillsButton = 'button'
    sharesButton = 'button'
    if selected == 'posts':
        postsButton = 'buttonselected'
    elif selected == 'following':
        followingButton = 'buttonselected'
    elif selected == 'followers':
        followersButton = 'buttonselected'
    elif selected == 'roles':
        rolesButton = 'buttonselected'
    elif selected == 'skills':
        skillsButton = 'buttonselected'
    elif selected == 'shares':
        sharesButton = 'buttonselected'
    loginButton = ''

    followApprovalsSection = ''
    followApprovals = False
    linkToTimelineStart = ''
    linkToTimelineEnd = ''
    editProfileStr = ''
    logoutStr = ''
    actor = profileJson['id']
    usersPath = '/users/' + actor.split('/users/')[1]

    donateSection = ''
    donateUrl = getDonationUrl(profileJson)
    PGPpubKey = getPGPpubKey(profileJson)
    PGPfingerprint = getPGPfingerprint(profileJson)
    emailAddress = getEmailAddress(profileJson)
    xmppAddress = getXmppAddress(profileJson)
    matrixAddress = getMatrixAddress(profileJson)
    ssbAddress = getSSBAddress(profileJson)
    toxAddress = getToxAddress(profileJson)
    if donateUrl or xmppAddress or matrixAddress or \
       ssbAddress or toxAddress or PGPpubKey or \
       PGPfingerprint or emailAddress:
        donateSection = '<div class="container">\n'
        donateSection += '  <center>\n'
        if donateUrl:
            donateSection += \
                '    <p><a href="' + donateUrl + \
                '"><button class="donateButton">' + translate['Donate'] + \
                '</button></a></p>\n'
        if emailAddress:
            donateSection += \
                '<p>' + translate['Email'] + ': <a href="mailto:' + \
                emailAddress + '">' + emailAddress + '</a></p>\n'
        if xmppAddress:
            donateSection += \
                '<p>' + translate['XMPP'] + ': <a href="xmpp:' + \
                xmppAddress + '">'+xmppAddress + '</a></p>\n'
        if matrixAddress:
            donateSection += \
                '<p>' + translate['Matrix'] + ': ' + matrixAddress + '</p>\n'
        if ssbAddress:
            donateSection += \
                '<p>SSB: <label class="ssbaddr">' + \
                ssbAddress + '</label></p>\n'
        if toxAddress:
            donateSection += \
                '<p>Tox: <label class="toxaddr">' + \
                toxAddress + '</label></p>\n'
        if PGPfingerprint:
            donateSection += \
                '<p class="pgp">PGP: ' + \
                PGPfingerprint.replace('\n', '<br>') + '</p>\n'
        if PGPpubKey:
            donateSection += \
                '<p class="pgp">' + PGPpubKey.replace('\n', '<br>') + '</p>\n'
        donateSection += '  </center>\n'
        donateSection += '</div>\n'

    if not authorized:
        loginButton = \
            '<br><a href="/login"><button class="loginButton">' + \
            translate['Login'] + '</button></a>'
    else:
        editProfileStr = \
            '<a href="' + usersPath + \
            '/editprofile"><button class="button"><span>' + \
            translate['Edit'] + ' </span></button></a>'
        logoutStr = \
            '<a href="/logout"><button class="button"><span>' + \
            translate['Logout'] + ' </span></button></a>'
        linkToTimelineStart = \
            '<a href="/users/' + nickname + '/' + defaultTimeline + \
            '"><label class="transparent">' + \
            translate['Switch to timeline view'] + '</label></a>'
        linkToTimelineStart += \
            '<a href="/users/' + nickname + '/' + defaultTimeline + \
            '" title="' + translate['Switch to timeline view'] + \
            '" alt="' + translate['Switch to timeline view'] + '">'
        linkToTimelineEnd = '</a>'
        # are there any follow requests?
        followRequestsFilename = \
            baseDir + '/accounts/' + \
            nickname + '@' + domain + '/followrequests.txt'
        if os.path.isfile(followRequestsFilename):
            with open(followRequestsFilename, 'r') as f:
                for line in f:
                    if len(line) > 0:
                        followApprovals = True
                        followersButton = 'buttonhighlighted'
                        if selected == 'followers':
                            followersButton = 'buttonselectedhighlighted'
                        break
        if selected == 'followers':
            if followApprovals:
                with open(followRequestsFilename, 'r') as f:
                    for followerHandle in f:
                        if len(line) > 0:
                            if '://' in followerHandle:
                                followerActor = followerHandle
                            else:
                                followerActor = \
                                    httpPrefix + '://' + \
                                    followerHandle.split('@')[1] + \
                                    '/users/' + followerHandle.split('@')[0]
                            basePath = '/users/' + nickname
                            followApprovalsSection += '<div class="container">'
                            followApprovalsSection += \
                                '<a href="' + followerActor + '">'
                            followApprovalsSection += \
                                '<span class="followRequestHandle">' + \
                                followerHandle + '</span></a>'
                            followApprovalsSection += \
                                '<a href="' + basePath + \
                                '/followapprove=' + followerHandle + '">'
                            followApprovalsSection += \
                                '<button class="followApprove">' + \
                                translate['Approve'] + '</button></a>'
                            followApprovalsSection += \
                                '<a href="' + basePath + \
                                '/followdeny=' + followerHandle + '">'
                            followApprovalsSection += \
                                '<button class="followDeny">' + \
                                translate['Deny'] + '</button></a>'
                            followApprovalsSection += '</div>'

    profileDescriptionShort = profileDescription
    if '\n' in profileDescription:
        if len(profileDescription.split('\n')) > 2:
            profileDescriptionShort = ''
    else:
        if '<br>' in profileDescription:
            if len(profileDescription.split('<br>')) > 2:
                profileDescriptionShort = ''
                profileDescription = profileDescription.replace('<br>', '\n')
    # keep the profile description short
    if len(profileDescriptionShort) > 256:
        profileDescriptionShort = ''
    # remove formatting from profile description used on title
    avatarDescription = ''
    if profileJson.get('summary'):
        avatarDescription = profileJson['summary'].replace('<br>', '\n')
        avatarDescription = avatarDescription.replace('<p>', '')
        avatarDescription = avatarDescription.replace('</p>', '')
    profileHeaderStr = '<div class="hero-image">'
    profileHeaderStr += '  <div class="hero-text">'
    profileHeaderStr += \
        '    <img loading="lazy" src="' + profileJson['icon']['url'] + \
        '" title="' + avatarDescription + '" alt="' + \
        avatarDescription + '" class="title">'
    profileHeaderStr += '    <h1>' + displayName + '</h1>'
    iconsDir = getIconsDir(baseDir)
    profileHeaderStr += \
        '<p><b>@' + nickname + '@' + domainFull + '</b><br>'
    profileHeaderStr += \
        '<a href="/users/' + nickname + \
        '/qrcode.png" alt="' + translate['QR Code'] + '" title="' + \
        translate['QR Code'] + '">' + \
        '<img class="qrcode" src="/' + iconsDir + '/qrcode.png" /></a></p>'
    profileHeaderStr += '    <p>' + profileDescriptionShort + '</p>'
    profileHeaderStr += loginButton
    profileHeaderStr += '  </div>'
    profileHeaderStr += '</div>'

    profileStr = \
        linkToTimelineStart + profileHeaderStr + \
        linkToTimelineEnd + donateSection
    profileStr += '<div class="container" id="buttonheader">\n'
    profileStr += '  <center>'
    profileStr += \
        '    <a href="' + usersPath + '#buttonheader"><button class="' + \
        postsButton + '"><span>' + translate['Posts'] + \
        ' </span></button></a>'
    profileStr += \
        '    <a href="' + usersPath + '/following#buttonheader">' + \
        '<button class="' + followingButton + '"><span>' + \
        translate['Following'] + ' </span></button></a>'
    profileStr += \
        '    <a href="' + usersPath + '/followers#buttonheader">' + \
        '<button class="' + followersButton + \
        '"><span>' + translate['Followers'] + ' </span></button></a>'
    profileStr += \
        '    <a href="' + usersPath + '/roles#buttonheader">' + \
        '<button class="' + rolesButton + '"><span>' + translate['Roles'] + \
        ' </span></button></a>'
    profileStr += \
        '    <a href="' + usersPath + '/skills#buttonheader">' + \
        '<button class="' + skillsButton + '"><span>' + \
        translate['Skills'] + ' </span></button></a>'
    profileStr += \
        '    <a href="' + usersPath + '/shares#buttonheader">' + \
        '<button class="' + sharesButton + '"><span>' + \
        translate['Shares'] + ' </span></button></a>'
    profileStr += editProfileStr + logoutStr
    profileStr += '  </center>'
    profileStr += '</div>'

    profileStr += followApprovalsSection

    cssFilename = baseDir + '/epicyon-profile.css'
    if os.path.isfile(baseDir + '/epicyon.css'):
        cssFilename = baseDir + '/epicyon.css'
    with open(cssFilename, 'r') as cssFile:
        profileStyle = \
            cssFile.read().replace('image.png',
                                   profileJson['image']['url'])

        licenseStr = \
            '<a href="https://gitlab.com/bashrc2/epicyon">' + \
            '<img loading="lazy" class="license" alt="' + \
            translate['Get the source code'] + '" title="' + \
            translate['Get the source code'] + '" src="/icons/agpl.png" /></a>'

        if selected == 'posts':
            profileStr += \
                htmlProfilePosts(recentPostsCache, maxRecentPosts,
                                 translate,
                                 baseDir, httpPrefix, authorized,
                                 ocapAlways, nickname, domain, port,
                                 session, wfRequest, personCache,
                                 projectVersion,
                                 YTReplacementDomain) + licenseStr
        if selected == 'following':
            profileStr += \
                htmlProfileFollowing(translate, baseDir, httpPrefix,
                                     authorized, ocapAlways, nickname,
                                     domain, port, session,
                                     wfRequest, personCache, extraJson,
                                     projectVersion, ["unfollow"], selected,
                                     usersPath, pageNumber, maxItemsPerPage)
        if selected == 'followers':
            profileStr += \
                htmlProfileFollowing(translate, baseDir, httpPrefix,
                                     authorized, ocapAlways, nickname,
                                     domain, port, session,
                                     wfRequest, personCache, extraJson,
                                     projectVersion, ["block"],
                                     selected, usersPath, pageNumber,
                                     maxItemsPerPage)
        if selected == 'roles':
            profileStr += \
                htmlProfileRoles(translate, nickname, domainFull, extraJson)
        if selected == 'skills':
            profileStr += \
                htmlProfileSkills(translate, nickname, domainFull, extraJson)
        if selected == 'shares':
            profileStr += \
                htmlProfileShares(actor, translate,
                                  nickname, domainFull,
                                  extraJson) + licenseStr
        profileStr = \
            htmlHeader(cssFilename, profileStyle) + profileStr + htmlFooter()
    return profileStr


def individualFollowAsHtml(translate: {},
                           baseDir: str, session, wfRequest: {},
                           personCache: {}, domain: str,
                           followUrl: str,
                           authorized: bool,
                           actorNickname: str,
                           httpPrefix: str,
                           projectVersion: str,
                           buttons=[]) -> str:
    """An individual follow entry on the profile screen
    """
    nickname = getNicknameFromActor(followUrl)
    domain, port = getDomainFromActor(followUrl)
    titleStr = '@' + nickname + '@' + domain
    avatarUrl = getPersonAvatarUrl(baseDir, followUrl, personCache, True)
    if not avatarUrl:
        avatarUrl = followUrl + '/avatar.png'
    if domain not in followUrl:
        (inboxUrl, pubKeyId, pubKey,
         fromPersonId, sharedInbox,
         capabilityAcquisition,
         avatarUrl2, displayName) = getPersonBox(baseDir, session, wfRequest,
                                                 personCache, projectVersion,
                                                 httpPrefix, nickname,
                                                 domain, 'outbox')
        if avatarUrl2:
            avatarUrl = avatarUrl2
        if displayName:
            titleStr = displayName + ' ' + titleStr

    buttonsStr = ''
    if authorized:
        for b in buttons:
            if b == 'block':
                buttonsStr += \
                    '<a href="/users/' + actorNickname + \
                    '?options=' + followUrl + \
                    ';1;' + avatarUrl + '"><button class="buttonunfollow">' + \
                    translate['Block'] + '</button></a>\n'
            if b == 'unfollow':
                buttonsStr += \
                    '<a href="/users/' + actorNickname + \
                    '?options=' + followUrl + \
                    ';1;' + avatarUrl + '"><button class="buttonunfollow">' + \
                    translate['Unfollow'] + '</button></a>\n'

    resultStr = '<div class="container">\n'
    resultStr += \
        '<a href="/users/' + actorNickname + '?options=' + \
        followUrl + ';1;' + avatarUrl + '">\n'
    resultStr += '<p><img loading="lazy" src="' + avatarUrl + '" alt=" ">'
    resultStr += titleStr + '</a>' + buttonsStr + '</p>\n'
    resultStr += '</div>\n'
    return resultStr


def addEmbeddedAudio(translate: {}, content: str) -> str:
    """Adds embedded audio for mp3/ogg
    """
    if not ('.mp3' in content or '.ogg' in content):
        return content

    if '<audio ' in content:
        return content

    extension = '.mp3'
    if '.ogg' in content:
        extension = '.ogg'

    words = content.strip('\n').split(' ')
    for w in words:
        if extension not in w:
            continue
        w = w.replace('href="', '').replace('">', '')
        if w.endswith('.'):
            w = w[:-1]
        if w.endswith('"'):
            w = w[:-1]
        if w.endswith(';'):
            w = w[:-1]
        if w.endswith(':'):
            w = w[:-1]
        if not w.endswith(extension):
            continue

        if not (w.startswith('http') or w.startswith('dat:') or
                w.startswith('hyper:') or w.startswith('i2p:') or
                w.startswith('gnunet:') or
                '/' in w):
            continue
        url = w
        content += '<center>\n<audio controls>\n'
        content += \
            '<source src="' + url + '" type="audio/' + \
            extension.replace('.', '') + '">'
        content += \
            translate['Your browser does not support the audio element.']
        content += '</audio>\n</center>\n'
    return content


def addEmbeddedVideo(translate: {}, content: str,
                     width=400, height=300) -> str:
    """Adds embedded video for mp4/webm/ogv
    """
    if not ('.mp4' in content or '.webm' in content or '.ogv' in content):
        return content

    if '<video ' in content:
        return content

    extension = '.mp4'
    if '.webm' in content:
        extension = '.webm'
    elif '.ogv' in content:
        extension = '.ogv'

    words = content.strip('\n').split(' ')
    for w in words:
        if extension not in w:
            continue
        w = w.replace('href="', '').replace('">', '')
        if w.endswith('.'):
            w = w[:-1]
        if w.endswith('"'):
            w = w[:-1]
        if w.endswith(';'):
            w = w[:-1]
        if w.endswith(':'):
            w = w[:-1]
        if not w.endswith(extension):
            continue
        if not (w.startswith('http') or w.startswith('dat:') or
                w.startswith('hyper:') or w.startswith('i2p:') or
                w.startswith('gnunet:') or
                '/' in w):
            continue
        url = w
        content += \
            '<center>\n<video width="' + str(width) + '" height="' + \
            str(height) + '" controls>\n'
        content += \
            '<source src="' + url + '" type="video/' + \
            extension.replace('.', '') + '">\n'
        content += \
            translate['Your browser does not support the video element.']
        content += '</video>\n</center>\n'
    return content


def addEmbeddedVideoFromSites(translate: {}, content: str,
                              width=400, height=300) -> str:
    """Adds embedded videos
    """
    if '>vimeo.com/' in content:
        url = content.split('>vimeo.com/')[1]
        if '<' in url:
            url = url.split('<')[0]
            content = \
                content + "<center>\n<iframe loading=\"lazy\" " + \
                "src=\"https://player.vimeo.com/video/" + \
                url + "\" width=\"" + str(width) + \
                "\" height=\"" + str(height) + \
                "\" frameborder=\"0\" allow=\"autoplay; " + \
                "fullscreen\" allowfullscreen></iframe>\n</center>\n"
            return content

    videoSite = 'https://www.youtube.com'
    if '"' + videoSite in content:
        url = content.split('"' + videoSite)[1]
        if '"' in url:
            url = url.split('"')[0].replace('/watch?v=', '/embed/')
            if '&' in url:
                url = url.split('&')[0]
            content = \
                content + "<center>\n<iframe loading=\"lazy\" src=\"" + \
                videoSite + url + "\" width=\"" + str(width) + \
                "\" height=\"" + str(height) + \
                "\" frameborder=\"0\" allow=\"autoplay; fullscreen\" " + \
                "allowfullscreen></iframe>\n</center>\n"
            return content

    invidiousSites = ('https://invidio.us',
                      'https://invidious.snopyta.org',
                      'http://c7hqkpkpemu6e7emz5b4vy' +
                      'z7idjgdvgaaa3dyimmeojqbgpea3xqjoid.onion',
                      'http://axqzx4s6s54s32yentfqojs3x5i7faxza6xo3ehd4' +
                      'bzzsg2ii4fv2iid.onion')
    for videoSite in invidiousSites:
        if '"' + videoSite in content:
            url = content.split('"' + videoSite)[1]
            if '"' in url:
                url = url.split('"')[0].replace('/watch?v=', '/embed/')
                if '&' in url:
                    url = url.split('&')[0]
                content = \
                    content + "<center>\n<iframe loading=\"lazy\" src=\"" + \
                    videoSite + url + "\" width=\"" + \
                    str(width) + "\" height=\"" + str(height) + \
                    "\" frameborder=\"0\" allow=\"autoplay; fullscreen\" " + \
                    "allowfullscreen></iframe>\n</center>\n"
                return content

    videoSite = 'https://media.ccc.de'
    if '"' + videoSite in content:
        url = content.split('"' + videoSite)[1]
        if '"' in url:
            url = url.split('"')[0]
            if not url.endswith('/oembed'):
                url = url + '/oembed'
            content = \
                content + "<center>\n<iframe loading=\"lazy\" src=\"" + \
                videoSite + url + "\" width=\"" + \
                str(width) + "\" height=\"" + str(height) + \
                "\" frameborder=\"0\" allow=\"fullscreen\" " + \
                "allowfullscreen></iframe>\n</center>\n"
            return content

    if '"https://' in content:
        # A selection of the current larger peertube sites, mostly
        # French and German language
        # These have been chosen based on reported numbers of users
        # and the content of each has not been reviewed, so mileage could vary
        peerTubeSites = ('peertube.mastodon.host', 'open.tube', 'share.tube',
                         'tube.tr4sk.me', 'videos.elbinario.net',
                         'hkvideo.live',
                         'peertube.snargol.com', 'tube.22decembre.eu',
                         'tube.fabrigli.fr', 'libretube.net', 'libre.video',
                         'peertube.linuxrocks.online', 'spacepub.space',
                         'video.ploud.jp', 'video.omniatv.com',
                         'peertube.servebeer.com',
                         'tube.tchncs.de', 'tubee.fr', 'video.alternanet.fr',
                         'devtube.dev-wiki.de', 'video.samedi.pm',
                         'video.irem.univ-paris-diderot.fr',
                         'peertube.openstreetmap.fr', 'video.antopie.org',
                         'scitech.video', 'tube.4aem.com', 'video.ploud.fr',
                         'peervideo.net', 'video.valme.io',
                         'videos.pair2jeux.tube',
                         'vault.mle.party', 'hostyour.tv',
                         'diode.zone', 'visionon.tv',
                         'artitube.artifaille.fr', 'peertube.fr',
                         'peertube.live',
                         'tube.ac-lyon.fr', 'www.yiny.org', 'betamax.video',
                         'tube.piweb.be', 'pe.ertu.be', 'peertube.social',
                         'videos.lescommuns.org', 'peertube.nogafa.org',
                         'skeptikon.fr', 'video.tedomum.net',
                         'tube.p2p.legal',
                         'sikke.fi', 'exode.me', 'peertube.video')
        for site in peerTubeSites:
            if '"https://' + site in content:
                url = content.split('"https://' + site)[1]
                if '"' in url:
                    url = url.split('"')[0].replace('/watch/', '/embed/')
                    content = \
                        content + "<center>\n<iframe loading=\"lazy\" " + \
                        "sandbox=\"allow-same-origin " + \
                        "allow-scripts\" src=\"https://" + \
                        site + url + "\" width=\"" + str(width) + \
                        "\" height=\"" + str(height) + \
                        "\" frameborder=\"0\" allow=\"autoplay; " + \
                        "fullscreen\" allowfullscreen></iframe>\n</center>\n"
                    return content
    return content


def addEmbeddedElements(translate: {}, content: str) -> str:
    """Adds embedded elements for various media types
    """
    content = addEmbeddedVideoFromSites(translate, content)
    content = addEmbeddedAudio(translate, content)
    return addEmbeddedVideo(translate, content)


def followerApprovalActive(baseDir: str, nickname: str, domain: str) -> bool:
    """Returns true if the given account requires follower approval
    """
    manuallyApprovesFollowers = False
    actorFilename = baseDir + '/accounts/' + nickname + '@' + domain + '.json'
    if os.path.isfile(actorFilename):
        actorJson = loadJson(actorFilename)
        if actorJson:
            if actorJson.get('manuallyApprovesFollowers'):
                manuallyApprovesFollowers = \
                    actorJson['manuallyApprovesFollowers']
    return manuallyApprovesFollowers


def insertQuestion(baseDir: str, translate: {},
                   nickname: str, domain: str, port: int,
                   content: str,
                   postJsonObject: {}, pageNumber: int) -> str:
    """ Inserts question selection into a post
    """
    if not isQuestion(postJsonObject):
        return content
    if len(postJsonObject['object']['oneOf']) == 0:
        return content
    messageId = removeIdEnding(postJsonObject['id'])
    if '#' in messageId:
        messageId = messageId.split('#', 1)[0]
    pageNumberStr = ''
    if pageNumber:
        pageNumberStr = '?page=' + str(pageNumber)

    votesFilename = \
        baseDir + '/accounts/' + nickname + '@' + domain + '/questions.txt'

    showQuestionResults = False
    if os.path.isfile(votesFilename):
        if messageId in open(votesFilename).read():
            showQuestionResults = True

    if not showQuestionResults:
        # show the question options
        content += '<div class="question">'
        content += \
            '<form method="POST" action="/users/' + \
            nickname + '/question' + pageNumberStr + '">\n'
        content += \
            '<input type="hidden" name="messageId" value="' + \
            messageId + '">\n<br>\n'
        for choice in postJsonObject['object']['oneOf']:
            if not choice.get('type'):
                continue
            if not choice.get('name'):
                continue
            content += \
                '<input type="radio" name="answer" value="' + \
                choice['name'] + '"> ' + choice['name'] + '<br><br>\n'
        content += \
            '<input type="submit" value="' + \
            translate['Vote'] + '" class="vote"><br><br>\n'
        content += '</form>\n</div>\n'
    else:
        # show the responses to a question
        content += '<div class="questionresult">\n'

        # get the maximum number of votes
        maxVotes = 1
        for questionOption in postJsonObject['object']['oneOf']:
            if not questionOption.get('name'):
                continue
            if not questionOption.get('replies'):
                continue
            votes = 0
            try:
                votes = int(questionOption['replies']['totalItems'])
            except BaseException:
                pass
            if votes > maxVotes:
                maxVotes = int(votes+1)

        # show the votes as sliders
        questionCtr = 1
        for questionOption in postJsonObject['object']['oneOf']:
            if not questionOption.get('name'):
                continue
            if not questionOption.get('replies'):
                continue
            votes = 0
            try:
                votes = int(questionOption['replies']['totalItems'])
            except BaseException:
                pass
            votesPercent = str(int(votes * 100 / maxVotes))
            content += \
                '<p><input type="text" title="' + str(votes) + \
                '" name="skillName' + str(questionCtr) + \
                '" value="' + questionOption['name'] + \
                ' (' + str(votes) + ')" style="width:40%">\n'
            content += \
                '<input type="range" min="1" max="100" ' + \
                'class="slider" title="' + \
                str(votes) + '" name="skillValue' + str(questionCtr) + \
                '" value="' + votesPercent + '"></p>\n'
            questionCtr += 1
        content += '</div>\n'
    return content


def addEmojiToDisplayName(baseDir: str, httpPrefix: str,
                          nickname: str, domain: str,
                          displayName: str, inProfileName: bool) -> str:
    """Adds emoji icons to display names on individual posts
    """
    if ':' not in displayName:
        return displayName

    displayName = displayName.replace('<p>', '').replace('</p>', '')
    emojiTags = {}
    print('TAG: displayName before tags: ' + displayName)
    displayName = \
        addHtmlTags(baseDir, httpPrefix,
                    nickname, domain, displayName, [], emojiTags)
    displayName = displayName.replace('<p>', '').replace('</p>', '')
    print('TAG: displayName after tags: ' + displayName)
    # convert the emoji dictionary to a list
    emojiTagsList = []
    for tagName, tag in emojiTags.items():
        emojiTagsList.append(tag)
    print('TAG: emoji tags list: ' + str(emojiTagsList))
    if not inProfileName:
        displayName = \
            replaceEmojiFromTags(displayName, emojiTagsList, 'post header')
    else:
        displayName = \
            replaceEmojiFromTags(displayName, emojiTagsList, 'profile')
    print('TAG: displayName after tags 2: ' + displayName)

    # remove any stray emoji
    while ':' in displayName:
        if '://' in displayName:
            break
        emojiStr = displayName.split(':')[1]
        prevDisplayName = displayName
        displayName = displayName.replace(':' + emojiStr + ':', '').strip()
        if prevDisplayName == displayName:
            break
        print('TAG: displayName after tags 3: ' + displayName)
    print('TAG: displayName after tag replacements: ' + displayName)

    return displayName


def postContainsPublic(postJsonObject: {}) -> bool:
    """Does the given post contain #Public
    """
    containsPublic = False
    if not postJsonObject['object'].get('to'):
        return containsPublic

    for toAddress in postJsonObject['object']['to']:
        if toAddress.endswith('#Public'):
            containsPublic = True
            break
        if not containsPublic:
            if postJsonObject['object'].get('cc'):
                for toAddress in postJsonObject['object']['cc']:
                    if toAddress.endswith('#Public'):
                        containsPublic = True
                        break
    return containsPublic


def loadIndividualPostAsHtmlFromCache(baseDir: str,
                                      nickname: str, domain: str,
                                      postJsonObject: {}) -> str:
    """If a cached html version of the given post exists then load it and
    return the html text
    This is much quicker than generating the html from the json object
    """
    cachedPostFilename = \
        getCachedPostFilename(baseDir, nickname, domain, postJsonObject)

    postHtml = ''
    if not cachedPostFilename:
        return postHtml

    if not os.path.isfile(cachedPostFilename):
        return postHtml

    tries = 0
    while tries < 3:
        try:
            with open(cachedPostFilename, 'r') as file:
                postHtml = file.read()
                break
        except Exception as e:
            print(e)
            # no sleep
            tries += 1
    if postHtml:
        return postHtml


def saveIndividualPostAsHtmlToCache(baseDir: str,
                                    nickname: str, domain: str,
                                    postJsonObject: {},
                                    postHtml: str) -> bool:
    """Saves the given html for a post to a cache file
    This is so that it can be quickly reloaded on subsequent
    refresh of the timeline
    """
    htmlPostCacheDir = \
        getCachedPostDirectory(baseDir, nickname, domain)
    cachedPostFilename = \
        getCachedPostFilename(baseDir, nickname, domain, postJsonObject)

    # create the cache directory if needed
    if not os.path.isdir(htmlPostCacheDir):
        os.mkdir(htmlPostCacheDir)

    try:
        with open(cachedPostFilename, 'w+') as fp:
            fp.write(postHtml)
            return True
    except Exception as e:
        print('ERROR: saving post to cache ' + str(e))
    return False


def preparePostFromHtmlCache(postHtml: str, boxName: str,
                             pageNumber: int) -> str:
    """Sets the page number on a cached html post
    """
    # if on the bookmarks timeline then remain there
    if boxName == 'tlbookmarks' or boxName == 'bookmarks':
        postHtml = postHtml.replace('?tl=inbox', '?tl=tlbookmarks')
        if '?page=' in postHtml:
            pageNumberStr = postHtml.split('?page=')[1]
            if '?' in pageNumberStr:
                pageNumberStr = pageNumberStr.split('?')[0]
            postHtml = postHtml.replace('?page=' + pageNumberStr, '?page=-999')

    withPageNumber = postHtml.replace(';-999;', ';' + str(pageNumber) + ';')
    withPageNumber = withPageNumber.replace('?page=-999',
                                            '?page=' + str(pageNumber))
    return withPageNumber


def postIsMuted(baseDir: str, nickname: str, domain: str,
                postJsonObject: {}, messageId: str) -> bool:
    """ Returns true if the given post is muted
    """
    isMuted = postJsonObject.get('muted')
    if isMuted is True or isMuted is False:
        return isMuted
    postDir = baseDir + '/accounts/' + nickname + '@' + domain
    muteFilename = \
        postDir + '/inbox/' + messageId.replace('/', '#') + '.json.muted'
    if os.path.isfile(muteFilename):
        return True
    muteFilename = \
        postDir + '/outbox/' + messageId.replace('/', '#') + '.json.muted'
    if os.path.isfile(muteFilename):
        return True
    muteFilename = \
        baseDir + '/accounts/cache/announce/' + nickname + \
        '/' + messageId.replace('/', '#') + '.json.muted'
    if os.path.isfile(muteFilename):
        return True
    return False


def getPostAttachmentsAsHtml(postJsonObject: {}, boxName: str, translate: {},
                             isMuted: bool, avatarLink: str,
                             replyStr: str, announceStr: str, likeStr: str,
                             bookmarkStr: str, deleteStr: str,
                             muteStr: str) -> (str, str):
    """Returns a string representing any attachments
    """
    attachmentStr = ''
    galleryStr = ''
    if not postJsonObject['object'].get('attachment'):
        return attachmentStr, galleryStr

    if not isinstance(postJsonObject['object']['attachment'], list):
        return attachmentStr, galleryStr

    attachmentCtr = 0
    attachmentStr += '<div class="media">\n'
    for attach in postJsonObject['object']['attachment']:
        if not (attach.get('mediaType') and attach.get('url')):
            continue

        mediaType = attach['mediaType']
        imageDescription = ''
        if attach.get('name'):
            imageDescription = attach['name'].replace('"', "'")
        if mediaType == 'image/png' or \
           mediaType == 'image/jpeg' or \
           mediaType == 'image/webp' or \
           mediaType == 'image/gif':
            if attach['url'].endswith('.png') or \
               attach['url'].endswith('.jpg') or \
               attach['url'].endswith('.jpeg') or \
               attach['url'].endswith('.webp') or \
               attach['url'].endswith('.gif'):
                if attachmentCtr > 0:
                    attachmentStr += '<br>'
                if boxName == 'tlmedia':
                    galleryStr += '<div class="gallery">\n'
                    if not isMuted:
                        galleryStr += '  <a href="' + attach['url'] + '">\n'
                        galleryStr += \
                            '    <img loading="lazy" src="' + \
                            attach['url'] + '" alt="" title="">\n'
                        galleryStr += '  </a>\n'
                    if postJsonObject['object'].get('url'):
                        imagePostUrl = postJsonObject['object']['url']
                    else:
                        imagePostUrl = postJsonObject['object']['id']
                    if imageDescription and not isMuted:
                        galleryStr += \
                            '  <a href="' + imagePostUrl + \
                            '" class="gallerytext"><div ' + \
                            'class="gallerytext">' + \
                            imageDescription + '</div></a>\n'
                    else:
                        galleryStr += \
                            '<label class="transparent">---</label><br>'
                    galleryStr += '  <div class="mediaicons">\n'
                    galleryStr += \
                        '    ' + replyStr+announceStr + likeStr + \
                        bookmarkStr + deleteStr + muteStr + '\n'
                    galleryStr += '  </div>\n'
                    galleryStr += '  <div class="mediaavatar">\n'
                    galleryStr += '    ' + avatarLink + '\n'
                    galleryStr += '  </div>\n'
                    galleryStr += '</div>\n'

                attachmentStr += '<a href="' + attach['url'] + '">'
                attachmentStr += \
                    '<img loading="lazy" src="' + attach['url'] + \
                    '" alt="' + imageDescription + '" title="' + \
                    imageDescription + '" class="attachment"></a>\n'
                attachmentCtr += 1
        elif (mediaType == 'video/mp4' or
              mediaType == 'video/webm' or
              mediaType == 'video/ogv'):
            extension = '.mp4'
            if attach['url'].endswith('.webm'):
                extension = '.webm'
            elif attach['url'].endswith('.ogv'):
                extension = '.ogv'
            if attach['url'].endswith(extension):
                if attachmentCtr > 0:
                    attachmentStr += '<br>'
                if boxName == 'tlmedia':
                    galleryStr += '<div class="gallery">\n'
                    if not isMuted:
                        galleryStr += '  <a href="' + attach['url'] + '">\n'
                        galleryStr += \
                            '    <video width="600" height="400" controls>\n'
                        galleryStr += \
                            '      <source src="' + attach['url'] + \
                            '" alt="' + imageDescription + \
                            '" title="' + imageDescription + \
                            '" class="attachment" type="video/' + \
                            extension.replace('.', '') + '">'
                        idx = 'Your browser does not support the video tag.'
                        galleryStr += translate[idx]
                        galleryStr += '    </video>\n'
                        galleryStr += '  </a>\n'
                    if postJsonObject['object'].get('url'):
                        videoPostUrl = postJsonObject['object']['url']
                    else:
                        videoPostUrl = postJsonObject['object']['id']
                    if imageDescription and not isMuted:
                        galleryStr += \
                            '  <a href="' + videoPostUrl + \
                            '" class="gallerytext"><div ' + \
                            'class="gallerytext">' + \
                            imageDescription + '</div></a>\n'
                    else:
                        galleryStr += \
                            '<label class="transparent">---</label><br>'
                    galleryStr += '  <div class="mediaicons">\n'
                    galleryStr += \
                        '    ' + replyStr + announceStr + likeStr + \
                        bookmarkStr + deleteStr + muteStr + '\n'
                    galleryStr += '  </div>\n'
                    galleryStr += '  <div class="mediaavatar">\n'
                    galleryStr += '    ' + avatarLink + '\n'
                    galleryStr += '  </div>\n'
                    galleryStr += '</div>\n'

                attachmentStr += \
                    '<center><video width="400" height="300" controls>'
                attachmentStr += \
                    '<source src="' + attach['url'] + '" alt="' + \
                    imageDescription + '" title="' + imageDescription + \
                    '" class="attachment" type="video/' + \
                    extension.replace('.', '') + '">'
                attachmentStr += \
                    translate['Your browser does not support the video tag.']
                attachmentStr += '</video></center>'
                attachmentCtr += 1
        elif (mediaType == 'audio/mpeg' or
              mediaType == 'audio/ogg'):
            extension = '.mp3'
            if attach['url'].endswith('.ogg'):
                extension = '.ogg'
            if attach['url'].endswith(extension):
                if attachmentCtr > 0:
                    attachmentStr += '<br>'
                if boxName == 'tlmedia':
                    galleryStr += '<div class="gallery">\n'
                    if not isMuted:
                        galleryStr += '  <a href="' + attach['url'] + '">\n'
                        galleryStr += '    <audio controls>\n'
                        galleryStr += \
                            '      <source src="' + attach['url'] + \
                            '" alt="' + imageDescription + \
                            '" title="' + imageDescription + \
                            '" class="attachment" type="audio/' + \
                            extension.replace('.', '') + '">'
                        idx = 'Your browser does not support the audio tag.'
                        galleryStr += translate[idx]
                        galleryStr += '    </audio>\n'
                        galleryStr += '  </a>\n'
                    if postJsonObject['object'].get('url'):
                        audioPostUrl = postJsonObject['object']['url']
                    else:
                        audioPostUrl = postJsonObject['object']['id']
                    if imageDescription and not isMuted:
                        galleryStr += \
                            '  <a href="' + audioPostUrl + \
                            '" class="gallerytext"><div ' + \
                            'class="gallerytext">' + \
                            imageDescription + '</div></a>\n'
                    else:
                        galleryStr += \
                            '<label class="transparent">---</label><br>'
                    galleryStr += '  <div class="mediaicons">\n'
                    galleryStr += \
                        '    ' + replyStr + announceStr + \
                        likeStr + bookmarkStr + \
                        deleteStr + muteStr+'\n'
                    galleryStr += '  </div>\n'
                    galleryStr += '  <div class="mediaavatar">\n'
                    galleryStr += '    ' + avatarLink + '\n'
                    galleryStr += '  </div>\n'
                    galleryStr += '</div>\n'

                attachmentStr += '<center>\n<audio controls>\n'
                attachmentStr += \
                    '<source src="' + attach['url'] + '" alt="' + \
                    imageDescription + '" title="' + imageDescription + \
                    '" class="attachment" type="audio/' + \
                    extension.replace('.', '') + '">'
                attachmentStr += \
                    translate['Your browser does not support the audio tag.']
                attachmentStr += '</audio>\n</center>\n'
                attachmentCtr += 1
    attachmentStr += '</div>'
    return attachmentStr, galleryStr


def individualPostAsHtml(allowDownloads: bool,
                         recentPostsCache: {}, maxRecentPosts: int,
                         iconsDir: str, translate: {},
                         pageNumber: int, baseDir: str,
                         session, wfRequest: {}, personCache: {},
                         nickname: str, domain: str, port: int,
                         postJsonObject: {},
                         avatarUrl: str, showAvatarOptions: bool,
                         allowDeletion: bool,
                         httpPrefix: str, projectVersion: str,
                         boxName: str, YTReplacementDomain: str,
                         showRepeats=True,
                         showIcons=False,
                         manuallyApprovesFollowers=False,
                         showPublicOnly=False,
                         storeToCache=True) -> str:
    """ Shows a single post as html
    """
    if not postJsonObject:
        return ''

    # benchmark
    postStartTime = time.time()

    postActor = postJsonObject['actor']

    # ZZZzzz
    if isPersonSnoozed(baseDir, nickname, domain, postActor):
        return ''

    # benchmark 1
    timeDiff = int((time.time() - postStartTime) * 1000)
    if timeDiff > 100:
        print('TIMING INDIV ' + boxName + ' 1 = ' + str(timeDiff))

    avatarPosition = ''
    messageId = ''
    if postJsonObject.get('id'):
        messageId = removeIdEnding(postJsonObject['id'])

    # benchmark 2
    timeDiff = int((time.time() - postStartTime) * 1000)
    if timeDiff > 100:
        print('TIMING INDIV ' + boxName + ' 2 = ' + str(timeDiff))

    messageIdStr = ''
    if messageId:
        messageIdStr = ';' + messageId

    fullDomain = domain
    if port:
        if port != 80 and port != 443:
            if ':' not in domain:
                fullDomain = domain + ':' + str(port)

    pageNumberParam = ''
    if pageNumber:
        pageNumberParam = '?page=' + str(pageNumber)

    if (not showPublicOnly and
        (storeToCache or boxName == 'bookmarks' or
         boxName == 'tlbookmarks') and
       boxName != 'tlmedia'):
        # update avatar if needed
        if not avatarUrl:
            avatarUrl = \
                getPersonAvatarUrl(baseDir, postActor, personCache,
                                   allowDownloads)

            # benchmark 2.1
            if not allowDownloads:
                timeDiff = int((time.time() - postStartTime) * 1000)
                if timeDiff > 100:
                    print('TIMING INDIV ' + boxName +
                          ' 2.1 = ' + str(timeDiff))

        updateAvatarImageCache(session, baseDir, httpPrefix,
                               postActor, avatarUrl, personCache,
                               allowDownloads)

        # benchmark 2.2
        if not allowDownloads:
            timeDiff = int((time.time() - postStartTime) * 1000)
            if timeDiff > 100:
                print('TIMING INDIV ' + boxName +
                      ' 2.2 = ' + str(timeDiff))

        postHtml = \
            loadIndividualPostAsHtmlFromCache(baseDir, nickname, domain,
                                              postJsonObject)
        if postHtml:
            postHtml = preparePostFromHtmlCache(postHtml, boxName, pageNumber)
            updateRecentPostsCache(recentPostsCache, maxRecentPosts,
                                   postJsonObject, postHtml)
            # benchmark 3
            if not allowDownloads:
                timeDiff = int((time.time() - postStartTime) * 1000)
                if timeDiff > 100:
                    print('TIMING INDIV ' + boxName +
                          ' 3 = ' + str(timeDiff))
            return postHtml

    # benchmark 4
    if not allowDownloads:
        timeDiff = int((time.time() - postStartTime) * 1000)
        if timeDiff > 100:
            print('TIMING INDIV ' + boxName + ' 4 = ' + str(timeDiff))

    if not avatarUrl:
        avatarUrl = \
            getPersonAvatarUrl(baseDir, postActor, personCache,
                               allowDownloads)
        avatarUrl = \
            updateAvatarImageCache(session, baseDir, httpPrefix,
                                   postActor, avatarUrl, personCache,
                                   allowDownloads)
    else:
        updateAvatarImageCache(session, baseDir, httpPrefix,
                               postActor, avatarUrl, personCache,
                               allowDownloads)

    # benchmark 5
    if not allowDownloads:
        timeDiff = int((time.time() - postStartTime) * 1000)
        if timeDiff > 100:
            print('TIMING INDIV ' + boxName + ' 5 = ' + str(timeDiff))

    if not avatarUrl:
        avatarUrl = postActor + '/avatar.png'

    if fullDomain not in postActor:
        (inboxUrl, pubKeyId, pubKey,
         fromPersonId, sharedInbox,
         capabilityAcquisition,
         avatarUrl2, displayName) = getPersonBox(baseDir, session, wfRequest,
                                                 personCache,
                                                 projectVersion, httpPrefix,
                                                 nickname, domain, 'outbox')
        # benchmark 6
        if not allowDownloads:
            timeDiff = int((time.time() - postStartTime) * 1000)
            if timeDiff > 100:
                print('TIMING INDIV ' + boxName + ' 6 = ' + str(timeDiff))

        if avatarUrl2:
            avatarUrl = avatarUrl2
        if displayName:
            if ':' in displayName:
                displayName = \
                    addEmojiToDisplayName(baseDir, httpPrefix,
                                          nickname, domain,
                                          displayName, False)

    # benchmark 7
    if not allowDownloads:
        timeDiff = int((time.time() - postStartTime) * 1000)
        if timeDiff > 100:
            print('TIMING INDIV ' + boxName + ' 7 = ' + str(timeDiff))

    avatarLink = '    <a class="imageAnchor" href="' + postActor + '">'
    avatarLink += \
        '    <img loading="lazy" src="' + avatarUrl + '" title="' + \
        translate['Show profile'] + '" alt=" "' + avatarPosition + '/></a>'

    if showAvatarOptions and \
       fullDomain + '/users/' + nickname not in postActor:
        avatarLink = \
            '    <a class="imageAnchor" href="/users/' + \
            nickname + '?options=' + postActor + \
            ';' + str(pageNumber) + ';' + avatarUrl + messageIdStr + '">\n'
        avatarLink += \
            '    <img loading="lazy" title="' + \
            translate['Show options for this person'] + \
            '" src="' + avatarUrl + '" ' + avatarPosition + '/></a>\n'
    avatarImageInPost = \
        '  <div class="timeline-avatar">' + avatarLink.strip() + '</div>\n'

    # don't create new html within the bookmarks timeline
    # it should already have been created for the inbox
    if boxName == 'tlbookmarks' or boxName == 'bookmarks':
        return ''

    timelinePostBookmark = removeIdEnding(postJsonObject['id'])
    timelinePostBookmark = timelinePostBookmark.replace('://', '-')
    timelinePostBookmark = timelinePostBookmark.replace('/', '-')

    # If this is the inbox timeline then don't show the repeat icon on any DMs
    showRepeatIcon = showRepeats
    isPublicRepeat = False
    showDMicon = False
    if showRepeats:
        if isDM(postJsonObject):
            showDMicon = True
            showRepeatIcon = False
        else:
            if not isPublicPost(postJsonObject):
                isPublicRepeat = True

    titleStr = ''
    galleryStr = ''
    isAnnounced = False
    if postJsonObject['type'] == 'Announce':
        postJsonAnnounce = \
            downloadAnnounce(session, baseDir, httpPrefix,
                             nickname, domain, postJsonObject,
                             projectVersion, translate,
                             YTReplacementDomain)
        if not postJsonAnnounce:
            return ''
        postJsonObject = postJsonAnnounce
        isAnnounced = True

    # benchmark 8
    if not allowDownloads:
        timeDiff = int((time.time() - postStartTime) * 1000)
        if timeDiff > 100:
            print('TIMING INDIV ' + boxName + ' 8 = ' + str(timeDiff))

    if not isinstance(postJsonObject['object'], dict):
        return ''

    # if this post should be public then check its recipients
    if showPublicOnly:
        if not postContainsPublic(postJsonObject):
            return ''

    isModerationPost = False
    if postJsonObject['object'].get('moderationStatus'):
        isModerationPost = True
    containerClass = 'container'
    containerClassIcons = 'containericons'
    timeClass = 'time-right'
    actorNickname = getNicknameFromActor(postActor)
    if not actorNickname:
        # single user instance
        actorNickname = 'dev'
    actorDomain, actorPort = getDomainFromActor(postActor)

    displayName = getDisplayName(baseDir, postActor, personCache)
    if displayName:
        if ':' in displayName:
            displayName = \
                addEmojiToDisplayName(baseDir, httpPrefix,
                                      nickname, domain,
                                      displayName, False)
        titleStr += \
            '<a class="imageAnchor" href="/users/' + \
            nickname + '?options=' + postActor + \
            ';' + str(pageNumber) + ';' + avatarUrl + messageIdStr + \
            '">' + displayName + '</a>\n'
    else:
        if not messageId:
            # pprint(postJsonObject)
            print('ERROR: no messageId')
        if not actorNickname:
            # pprint(postJsonObject)
            print('ERROR: no actorNickname')
        if not actorDomain:
            # pprint(postJsonObject)
            print('ERROR: no actorDomain')
        titleStr += \
            '<a class="imageAnchor" href="/users/' + \
            nickname + '?options=' + postActor + \
            ';' + str(pageNumber) + ';' + avatarUrl + messageIdStr + \
            '">@' + actorNickname + '@' + actorDomain + '</a>\n'

    # benchmark 9
    if not allowDownloads:
        timeDiff = int((time.time() - postStartTime) * 1000)
        if timeDiff > 100:
            print('TIMING INDIV ' + boxName + ' 9 = ' + str(timeDiff))

    # Show a DM icon for DMs in the inbox timeline
    if showDMicon:
        titleStr = \
            titleStr + ' <img loading="lazy" src="/' + \
            iconsDir + '/dm.png" class="DMicon"/>\n'

    replyStr = ''
    # check if replying is permitted
    commentsEnabled = True
    if 'commentsEnabled' in postJsonObject['object']:
        if postJsonObject['object']['commentsEnabled'] is False:
            commentsEnabled = False
    if showIcons and commentsEnabled:
        # reply is permitted - create reply icon
        replyToLink = postJsonObject['object']['id']
        if postJsonObject['object'].get('attributedTo'):
            if isinstance(postJsonObject['object']['attributedTo'], str):
                replyToLink += \
                    '?mention=' + postJsonObject['object']['attributedTo']
        if postJsonObject['object'].get('content'):
            mentionedActors = \
                getMentionsFromHtml(postJsonObject['object']['content'])
            if mentionedActors:
                for actorUrl in mentionedActors:
                    if '?mention=' + actorUrl not in replyToLink:
                        replyToLink += '?mention=' + actorUrl
                        if len(replyToLink) > 500:
                            break
        replyToLink += pageNumberParam

        replyStr = ''
        if isPublicRepeat:
            replyStr += \
                '<a class="imageAnchor" href="/users/' + \
                nickname + '?replyto=' + replyToLink + \
                '?actor=' + postJsonObject['actor'] + \
                '" title="' + translate['Reply to this post'] + '">\n'
        else:
            if isDM(postJsonObject):
                replyStr += \
                    '<a class="imageAnchor" href="/users/' + nickname + \
                    '?replydm=' + replyToLink + \
                    '?actor=' + postJsonObject['actor'] + \
                    '" title="' + translate['Reply to this post'] + '">\n'
            else:
                replyStr += \
                    '<a class="imageAnchor" href="/users/' + nickname + \
                    '?replyfollowers=' + replyToLink + \
                    '?actor=' + postJsonObject['actor'] + \
                    '" title="' + translate['Reply to this post'] + '">\n'

        replyStr += \
            '<img loading="lazy" title="' + \
            translate['Reply to this post'] + '" alt="' + \
            translate['Reply to this post'] + \
            ' |" src="/' + iconsDir + '/reply.png"/></a>\n'

    # benchmark 10
    if not allowDownloads:
        timeDiff = int((time.time() - postStartTime) * 1000)
        if timeDiff > 100:
            print('TIMING INDIV ' + boxName + ' 10 = ' + str(timeDiff))

    isEvent = isEventPost(postJsonObject)

    # benchmark 11
    if not allowDownloads:
        timeDiff = int((time.time() - postStartTime) * 1000)
        if timeDiff > 100:
            print('TIMING INDIV ' + boxName + ' 11 = ' + str(timeDiff))

    editStr = ''
    if fullDomain + '/users/' + nickname in postJsonObject['actor']:
        if '/statuses/' in postJsonObject['object']['id']:
            if isBlogPost(postJsonObject):
                blogPostId = postJsonObject['object']['id']
                editStr += \
                    '<a class="imageAnchor" href="/users/' + nickname + \
                    '/tlblogs?editblogpost=' + \
                    blogPostId.split('/statuses/')[1] + \
                    '?actor=' + actorNickname + \
                    '" title="' + translate['Edit blog post'] + '">' + \
                    '<img loading="lazy" title="' + \
                    translate['Edit blog post'] + '" alt="' + \
                    translate['Edit blog post'] + \
                    ' |" src="/' + iconsDir + '/edit.png"/></a>\n'
            elif isEvent:
                eventPostId = postJsonObject['object']['id']
                editStr += \
                    '<a class="imageAnchor" href="/users/' + nickname + \
                    '/tlblogs?editeventpost=' + \
                    eventPostId.split('/statuses/')[1] + \
                    '?actor=' + actorNickname + \
                    '" title="' + translate['Edit event'] + '">' + \
                    '<img loading="lazy" title="' + \
                    translate['Edit event'] + '" alt="' + \
                    translate['Edit event'] + \
                    ' |" src="/' + iconsDir + '/edit.png"/></a>\n'

    announceStr = ''
    if not isModerationPost and showRepeatIcon:
        # don't allow announce/repeat of your own posts
        announceIcon = 'repeat_inactive.png'
        announceLink = 'repeat'
        if not isPublicRepeat:
            announceLink = 'repeatprivate'
        announceTitle = translate['Repeat this post']
        if announcedByPerson(postJsonObject, nickname, fullDomain):
            announceIcon = 'repeat.png'
            if not isPublicRepeat:
                announceLink = 'unrepeatprivate'
            announceTitle = translate['Undo the repeat']
        announceStr = \
            '<a class="imageAnchor" href="/users/' + \
            nickname + '?' + announceLink + \
            '=' + postJsonObject['object']['id'] + pageNumberParam + \
            '?actor=' + postJsonObject['actor'] + \
            '?bm=' + timelinePostBookmark + \
            '?tl=' + boxName + '" title="' + announceTitle + '">\n'
        announceStr += \
            '<img loading="lazy" title="' + translate['Repeat this post'] + \
            '" alt="' + translate['Repeat this post'] + \
            ' |" src="/' + iconsDir + '/' + announceIcon + '"/></a>\n'

    # benchmark 12
    if not allowDownloads:
        timeDiff = int((time.time() - postStartTime) * 1000)
        if timeDiff > 100:
            print('TIMING INDIV ' + boxName + ' 12 = ' + str(timeDiff))

    # whether to show a like button
    hideLikeButtonFile = \
        baseDir + '/accounts/' + nickname + '@' + domain + '/.hideLikeButton'
    showLikeButton = True
    if os.path.isfile(hideLikeButtonFile):
        showLikeButton = False

    likeStr = ''
    if not isModerationPost and showLikeButton:
        likeIcon = 'like_inactive.png'
        likeLink = 'like'
        likeTitle = translate['Like this post']
        likeCount = noOfLikes(postJsonObject)

        # benchmark 12.1
        if not allowDownloads:
            timeDiff = int((time.time() - postStartTime) * 1000)
            if timeDiff > 100:
                print('TIMING INDIV ' + boxName + ' 12.1 = ' + str(timeDiff))

        likeCountStr = ''
        if likeCount > 0:
            if likeCount > 1:
                if likeCount <= 10:
                    likeCountStr = ' (' + str(likeCount) + ')'
                else:
                    likeCountStr = ' (10+)'
            likeIcon = 'like.png'
            if likedByPerson(postJsonObject, nickname, fullDomain):
                likeLink = 'unlike'
                likeTitle = translate['Undo the like']

        # benchmark 12.2
        if not allowDownloads:
            timeDiff = int((time.time() - postStartTime) * 1000)
            if timeDiff > 100:
                print('TIMING INDIV ' + boxName + ' 12.2 = ' + str(timeDiff))

        likeStr = \
            '<a class="imageAnchor" href="/users/' + nickname + '?' + \
            likeLink + '=' + postJsonObject['object']['id'] + \
            pageNumberParam + \
            '?actor=' + postJsonObject['actor'] + \
            '?bm=' + timelinePostBookmark + \
            '?tl=' + boxName + '" title="' + \
            likeTitle + likeCountStr + '">\n'
        likeStr += \
            '<img loading="lazy" title="' + likeTitle + likeCountStr + \
            '" alt="' + likeTitle + \
            ' |" src="/' + iconsDir + '/' + likeIcon + '"/></a>\n'

    # benchmark 12.5
    if not allowDownloads:
        timeDiff = int((time.time() - postStartTime) * 1000)
        if timeDiff > 100:
            print('TIMING INDIV ' + boxName + ' 12.5 = ' + str(timeDiff))

    bookmarkStr = ''
    if not isModerationPost:
        bookmarkIcon = 'bookmark_inactive.png'
        bookmarkLink = 'bookmark'
        bookmarkTitle = translate['Bookmark this post']
        if bookmarkedByPerson(postJsonObject, nickname, fullDomain):
            bookmarkIcon = 'bookmark.png'
            bookmarkLink = 'unbookmark'
            bookmarkTitle = translate['Undo the bookmark']
        # benchmark 12.6
        if not allowDownloads:
            timeDiff = int((time.time() - postStartTime) * 1000)
            if timeDiff > 100:
                print('TIMING INDIV ' + boxName + ' 12.6 = ' + str(timeDiff))
        bookmarkStr = \
            '<a class="imageAnchor" href="/users/' + nickname + '?' + \
            bookmarkLink + '=' + postJsonObject['object']['id'] + \
            pageNumberParam + \
            '?actor=' + postJsonObject['actor'] + \
            '?bm=' + timelinePostBookmark + \
            '?tl=' + boxName + '" title="' + bookmarkTitle + '">\n'
        bookmarkStr += \
            '<img loading="lazy" title="' + bookmarkTitle + '" alt="' + \
            bookmarkTitle + ' |" src="/' + iconsDir + \
            '/' + bookmarkIcon + '"/></a>\n'

    # benchmark 12.9
    if not allowDownloads:
        timeDiff = int((time.time() - postStartTime) * 1000)
        if timeDiff > 100:
            print('TIMING INDIV ' + boxName + ' 12.9 = ' + str(timeDiff))

    isMuted = postIsMuted(baseDir, nickname, domain, postJsonObject, messageId)

    # benchmark 13
    if not allowDownloads:
        timeDiff = int((time.time() - postStartTime) * 1000)
        if timeDiff > 100:
            print('TIMING INDIV ' + boxName + ' 13 = ' + str(timeDiff))

    deleteStr = ''
    muteStr = ''
    if (allowDeletion or
        ('/' + fullDomain + '/' in postActor and
         messageId.startswith(postActor))):
        if '/users/' + nickname + '/' in messageId:
            deleteStr = \
                '<a class="imageAnchor" href="/users/' + nickname + \
                '?delete=' + messageId + pageNumberParam + \
                '" title="' + translate['Delete this post'] + '">\n'
            deleteStr += \
                '<img loading="lazy" alt="' + translate['Delete this post'] + \
                ' |" title="' + translate['Delete this post'] + \
                '" src="/' + iconsDir + '/delete.png"/></a>\n'
    else:
        if not isMuted:
            muteStr = \
                '<a class="imageAnchor" href="/users/' + nickname + \
                '?mute=' + messageId + pageNumberParam + '?tl=' + boxName + \
                '?bm=' + timelinePostBookmark + \
                '" title="' + translate['Mute this post'] + '">\n'
            muteStr += \
                '<img loading="lazy" alt="' + \
                translate['Mute this post'] + \
                ' |" title="' + translate['Mute this post'] + \
                '" src="/' + iconsDir + '/mute.png"/></a>\n'
        else:
            muteStr = \
                '<a class="imageAnchor" href="/users/' + \
                nickname + '?unmute=' + messageId + \
                pageNumberParam + '?tl=' + boxName + '?bm=' + \
                timelinePostBookmark + '" title="' + \
                translate['Undo mute'] + '">\n'
            muteStr += \
                '<img loading="lazy" alt="' + translate['Undo mute'] + \
                ' |" title="' + translate['Undo mute'] + \
                '" src="/' + iconsDir+'/unmute.png"/></a>\n'

    # benchmark 13.1
    if not allowDownloads:
        timeDiff = int((time.time() - postStartTime) * 1000)
        if timeDiff > 100:
            print('TIMING INDIV ' + boxName + ' 13.1 = ' + str(timeDiff))

    replyAvatarImageInPost = ''
    if showRepeatIcon:
        if isAnnounced:
            if postJsonObject['object'].get('attributedTo'):
                attributedTo = ''
                if isinstance(postJsonObject['object']['attributedTo'], str):
                    attributedTo = postJsonObject['object']['attributedTo']
                if attributedTo.startswith(postActor):
                    titleStr += \
                        ' <img loading="lazy" title="' + \
                        translate['announces'] + \
                        '" alt="' + translate['announces'] + \
                        '" src="/' + iconsDir + \
                        '/repeat_inactive.png" class="announceOrReply"/>\n'
                else:
                    # benchmark 13.2
                    if not allowDownloads:
                        timeDiff = int((time.time() - postStartTime) * 1000)
                        if timeDiff > 100:
                            print('TIMING INDIV ' + boxName +
                                  ' 13.2 = ' + str(timeDiff))
                    announceNickname = None
                    if attributedTo:
                        announceNickname = getNicknameFromActor(attributedTo)
                    if announceNickname:
                        announceDomain, announcePort = \
                            getDomainFromActor(attributedTo)
                        getPersonFromCache(baseDir, attributedTo,
                                           personCache, allowDownloads)
                        announceDisplayName = \
                            getDisplayName(baseDir, attributedTo, personCache)
                        if announceDisplayName:
                            # benchmark 13.3
                            if not allowDownloads:
                                timeDiff = \
                                    int((time.time() - postStartTime) * 1000)
                                if timeDiff > 100:
                                    print('TIMING INDIV ' + boxName +
                                          ' 13.3 = ' + str(timeDiff))

                            if ':' in announceDisplayName:
                                announceDisplayName = \
                                    addEmojiToDisplayName(baseDir, httpPrefix,
                                                          nickname, domain,
                                                          announceDisplayName,
                                                          False)
                            # benchmark 13.3.1
                            if not allowDownloads:
                                timeDiff = \
                                    int((time.time() - postStartTime) * 1000)
                                if timeDiff > 100:
                                    print('TIMING INDIV ' + boxName +
                                          ' 13.3.1 = ' + str(timeDiff))

                            titleStr += \
                                ' <img loading="lazy" title="' + \
                                translate['announces'] + '" alt="' + \
                                translate['announces'] + '" src="/' + \
                                iconsDir + '/repeat_inactive.png" ' + \
                                'class="announceOrReply"/> <a href="' + \
                                postJsonObject['object']['id'] + '">' + \
                                announceDisplayName + '</a>\n'
                            # show avatar of person replied to
                            announceActor = \
                                postJsonObject['object']['attributedTo']
                            announceAvatarUrl = \
                                getPersonAvatarUrl(baseDir, announceActor,
                                                   personCache, allowDownloads)

                            # benchmark 13.4
                            if not allowDownloads:
                                timeDiff = \
                                    int((time.time() - postStartTime) * 1000)
                                if timeDiff > 100:
                                    print('TIMING INDIV ' + boxName +
                                          ' 13.4 = ' + str(timeDiff))

                            if announceAvatarUrl:
                                idx = 'Show options for this person'
                                replyAvatarImageInPost = \
                                    '<div class="timeline-avatar-reply">\n' \
                                    '<a class="imageAnchor" ' + \
                                    'href="/users/' + nickname + \
                                    '?options=' + \
                                    announceActor + ';' + str(pageNumber) + \
                                    ';' + announceAvatarUrl + \
                                    messageIdStr + '">' \
                                    '<img loading="lazy" src="' + \
                                    announceAvatarUrl + '" ' \
                                    'title="' + translate[idx] + \
                                    '" alt=" "' + avatarPosition + \
                                    '/></a>\n</div>\n'
                        else:
                            titleStr += \
                                ' <img loading="lazy" title="' + \
                                translate['announces'] + \
                                '" alt="' + translate['announces'] + \
                                '" src="/' + iconsDir + \
                                '/repeat_inactive.png" ' + \
                                'class="announceOrReply"/> <a href="' + \
                                postJsonObject['object']['id'] + '">@' + \
                                announceNickname + '@' + \
                                announceDomain + '</a>\n'
                    else:
                        titleStr += \
                            ' <img loading="lazy" title="' + \
                            translate['announces'] + '" alt="' + \
                            translate['announces'] + '" src="/' + iconsDir + \
                            '/repeat_inactive.png" ' + \
                            'class="announceOrReply"/> <a href="' + \
                            postJsonObject['object']['id'] + \
                            '">@unattributed</a>\n'
            else:
                titleStr += \
                    ' <img loading="lazy" title="' + translate['announces'] + \
                    '" alt="' + translate['announces'] + \
                    '" src="/' + iconsDir + \
                    '/repeat_inactive.png" ' + \
                    'class="announceOrReply"/> <a href="' + \
                    postJsonObject['object']['id'] + '">@unattributed</a>\n'
        else:
            if postJsonObject['object'].get('inReplyTo'):
                containerClassIcons = 'containericons darker'
                containerClass = 'container darker'
                if postJsonObject['object']['inReplyTo'].startswith(postActor):
                    titleStr += \
                        ' <img loading="lazy" title="' + \
                        translate['replying to themselves'] + \
                        '" alt="' + translate['replying to themselves'] + \
                        '" src="/' + iconsDir + \
                        '/reply.png" class="announceOrReply"/>\n'
                else:
                    if '/statuses/' in postJsonObject['object']['inReplyTo']:
                        inReplyTo = postJsonObject['object']['inReplyTo']
                        replyActor = inReplyTo.split('/statuses/')[0]
                        replyNickname = getNicknameFromActor(replyActor)
                        if replyNickname:
                            replyDomain, replyPort = \
                                getDomainFromActor(replyActor)
                            if replyNickname and replyDomain:
                                getPersonFromCache(baseDir, replyActor,
                                                   personCache,
                                                   allowDownloads)
                                replyDisplayName = \
                                    getDisplayName(baseDir, replyActor,
                                                   personCache)
                                if replyDisplayName:
                                    if ':' in replyDisplayName:
                                        # benchmark 13.5
                                        if not allowDownloads:
                                            timeDiff = \
                                                int((time.time() -
                                                     postStartTime) * 1000)
                                            if timeDiff > 100:
                                                print('TIMING INDIV ' +
                                                      boxName + ' 13.5 = ' +
                                                      str(timeDiff))
                                        repDisp = replyDisplayName
                                        replyDisplayName = \
                                            addEmojiToDisplayName(baseDir,
                                                                  httpPrefix,
                                                                  nickname,
                                                                  domain,
                                                                  repDisp,
                                                                  False)
                                        # benchmark 13.6
                                        if not allowDownloads:
                                            timeDiff = \
                                                int((time.time() -
                                                     postStartTime) * 1000)
                                            if timeDiff > 100:
                                                print('TIMING INDIV ' +
                                                      boxName + ' 13.6 = ' +
                                                      str(timeDiff))
                                    titleStr += \
                                        ' <img loading="lazy" title="' + \
                                        translate['replying to'] + \
                                        '" alt="' + \
                                        translate['replying to'] + \
                                        '" src="/' + \
                                        iconsDir + '/reply.png" ' + \
                                        'class="announceOrReply"/> ' + \
                                        '<a href="' + inReplyTo + \
                                        '">' + replyDisplayName + '</a>\n'

                                    # benchmark 13.7
                                    if not allowDownloads:
                                        timeDiff = int((time.time() -
                                                        postStartTime) * 1000)
                                        if timeDiff > 100:
                                            print('TIMING INDIV ' + boxName +
                                                  ' 13.7 = ' + str(timeDiff))

                                    # show avatar of person replied to
                                    replyAvatarUrl = \
                                        getPersonAvatarUrl(baseDir,
                                                           replyActor,
                                                           personCache,
                                                           allowDownloads)

                                    # benchmark 13.8
                                    if not allowDownloads:
                                        timeDiff = int((time.time() -
                                                        postStartTime) * 1000)
                                        if timeDiff > 100:
                                            print('TIMING INDIV ' + boxName +
                                                  ' 13.8 = ' + str(timeDiff))

                                    if replyAvatarUrl:
                                        replyAvatarImageInPost = \
                                            '<div class=' + \
                                            '"timeline-avatar-reply">\n'
                                        replyAvatarImageInPost += \
                                            '<a class="imageAnchor" ' + \
                                            'href="/users/' + nickname + \
                                            '?options=' + replyActor + \
                                            ';' + str(pageNumber) + ';' + \
                                            replyAvatarUrl + \
                                            messageIdStr + '">\n'
                                        replyAvatarImageInPost += \
                                            '<img loading="lazy" src="' + \
                                            replyAvatarUrl + '" '
                                        replyAvatarImageInPost += \
                                            'title="' + \
                                            translate['Show profile']
                                        replyAvatarImageInPost += \
                                            '" alt=" "' + \
                                            avatarPosition + '/></a>\n</div>\n'
                                else:
                                    inReplyTo = \
                                        postJsonObject['object']['inReplyTo']
                                    titleStr += \
                                        ' <img loading="lazy" title="' + \
                                        translate['replying to'] + \
                                        '" alt="' + \
                                        translate['replying to'] + \
                                        '" src="/' + \
                                        iconsDir + '/reply.png" ' + \
                                        'class="announceOrReply"/> ' + \
                                        '<a href="' + \
                                        inReplyTo + '">@' + \
                                        replyNickname + '@' + \
                                        replyDomain + '</a>\n'
                        else:
                            titleStr += \
                                ' <img loading="lazy" title="' + \
                                translate['replying to'] + \
                                '" alt="' + \
                                translate['replying to'] + \
                                '" src="/' + \
                                iconsDir + \
                                '/reply.png" class="announceOrReply"/> ' + \
                                '<a href="' + \
                                postJsonObject['object']['inReplyTo'] + \
                                '">@unknown</a>\n'
                    else:
                        postDomain = \
                            postJsonObject['object']['inReplyTo']
                        prefixes = getProtocolPrefixes()
                        for prefix in prefixes:
                            postDomain = postDomain.replace(prefix, '')
                        if '/' in postDomain:
                            postDomain = postDomain.split('/', 1)[0]
                        if postDomain:
                            titleStr += \
                                ' <img loading="lazy" title="' + \
                                translate['replying to'] + \
                                '" alt="' + translate['replying to'] + \
                                '" src="/' + \
                                iconsDir + '/reply.png" ' + \
                                'class="announceOrReply"/> <a href="' + \
                                postJsonObject['object']['inReplyTo'] + \
                                '">' + postDomain + '</a>\n'

    # benchmark 14
    if not allowDownloads:
        timeDiff = int((time.time() - postStartTime) * 1000)
        if timeDiff > 100:
            print('TIMING INDIV ' + boxName + ' 14 = ' + str(timeDiff))

    attachmentStr, galleryStr = \
        getPostAttachmentsAsHtml(postJsonObject, boxName, translate,
                                 isMuted, avatarLink.strip(),
                                 replyStr, announceStr, likeStr,
                                 bookmarkStr, deleteStr, muteStr)

    publishedStr = ''
    if postJsonObject['object'].get('published'):
        publishedStr = postJsonObject['object']['published']
        if '.' not in publishedStr:
            if '+' not in publishedStr:
                datetimeObject = \
                    datetime.strptime(publishedStr, "%Y-%m-%dT%H:%M:%SZ")
            else:
                datetimeObject = \
                    datetime.strptime(publishedStr.split('+')[0] + 'Z',
                                      "%Y-%m-%dT%H:%M:%SZ")
        else:
            publishedStr = \
                publishedStr.replace('T', ' ').split('.')[0]
            datetimeObject = parse(publishedStr)
        publishedStr = datetimeObject.strftime("%a %b %d, %H:%M")

    # benchmark 15
    if not allowDownloads:
        timeDiff = int((time.time() - postStartTime) * 1000)
        if timeDiff > 100:
            print('TIMING INDIV ' + boxName + ' 15 = ' + str(timeDiff))

    publishedLink = messageId
    # blog posts should have no /statuses/ in their link
    if isBlogPost(postJsonObject):
        # is this a post to the local domain?
        if '://' + domain in messageId:
            publishedLink = messageId.replace('/statuses/', '/')
    # if this is a local link then make it relative so that it works
    # on clearnet or onion address
    if domain + '/users/' in publishedLink or \
       domain + ':' + str(port) + '/users/' in publishedLink:
        publishedLink = '/users/' + publishedLink.split('/users/')[1]

    footerStr = '<a href="' + publishedLink + \
        '" class="' + timeClass + '">' + publishedStr + '</a>\n'

    # change the background color for DMs in inbox timeline
    if showDMicon:
        containerClassIcons = 'containericons dm'
        containerClass = 'container dm'

    if showIcons:
        footerStr = '<div class="' + containerClassIcons + '">'
        footerStr += replyStr + announceStr + likeStr + bookmarkStr + \
            deleteStr + muteStr + editStr
        footerStr += '<a href="' + publishedLink + '" class="' + \
            timeClass + '">' + publishedStr + '</a>\n'
        footerStr += '</div>'

    postIsSensitive = False
    if postJsonObject['object'].get('sensitive'):
        # sensitive posts should have a summary
        if postJsonObject['object'].get('summary'):
            postIsSensitive = postJsonObject['object']['sensitive']
        else:
            # add a generic summary if none is provided
            postJsonObject['object']['summary'] = translate['Sensitive']

    # add an extra line if there is a content warning,
    # for better vertical spacing on mobile
    if postIsSensitive:
        footerStr = '<br>' + footerStr

    if not postJsonObject['object'].get('summary'):
        postJsonObject['object']['summary'] = ''

    if postJsonObject['object'].get('cipherText'):
        postJsonObject['object']['content'] = \
            E2EEdecryptMessageFromDevice(postJsonObject['object'])

    if not postJsonObject['object'].get('content'):
        return ''

    isPatch = isGitPatch(baseDir, nickname, domain,
                         postJsonObject['object']['type'],
                         postJsonObject['object']['summary'],
                         postJsonObject['object']['content'])

    # benchmark 16
    if not allowDownloads:
        timeDiff = int((time.time() - postStartTime) * 1000)
        if timeDiff > 100:
            print('TIMING INDIV ' + boxName + ' 16 = ' + str(timeDiff))

    if not isPatch:
        objectContent = \
            removeLongWords(postJsonObject['object']['content'], 40, [])
        objectContent = removeTextFormatting(objectContent)
        objectContent = \
            switchWords(baseDir, nickname, domain, objectContent)
        objectContent = htmlReplaceQuoteMarks(objectContent)
    else:
        objectContent = \
            postJsonObject['object']['content']

    if not postIsSensitive:
        contentStr = objectContent + attachmentStr
        contentStr = addEmbeddedElements(translate, contentStr)
        contentStr = insertQuestion(baseDir, translate,
                                    nickname, domain, port,
                                    contentStr, postJsonObject,
                                    pageNumber)
    else:
        postID = 'post' + str(createPassword(8))
        contentStr = ''
        if postJsonObject['object'].get('summary'):
            contentStr += \
                '<b>' + postJsonObject['object']['summary'] + '</b>\n '
            if isModerationPost:
                containerClass = 'container report'
        # get the content warning text
        cwContentStr = objectContent + attachmentStr
        if not isPatch:
            cwContentStr = addEmbeddedElements(translate, cwContentStr)
            cwContentStr = \
                insertQuestion(baseDir, translate, nickname, domain, port,
                               cwContentStr, postJsonObject, pageNumber)
        if not isBlogPost(postJsonObject):
            # get the content warning button
            contentStr += \
                getContentWarningButton(postID, translate, cwContentStr)
        else:
            contentStr += cwContentStr

    # benchmark 17
    if not allowDownloads:
        timeDiff = int((time.time() - postStartTime) * 1000)
        if timeDiff > 100:
            print('TIMING INDIV ' + boxName + ' 17 = ' + str(timeDiff))

    if postJsonObject['object'].get('tag') and not isPatch:
        contentStr = \
            replaceEmojiFromTags(contentStr,
                                 postJsonObject['object']['tag'],
                                 'content')

    if isMuted:
        contentStr = ''
    else:
        if not isPatch:
            contentStr = '<div class="message">' + contentStr + '</div>\n'
        else:
            contentStr = \
                '<div class="gitpatch"><pre><code>' + contentStr + \
                '</code></pre></div>\n'

    postHtml = ''
    if boxName != 'tlmedia':
        postHtml = '<div id="' + timelinePostBookmark + \
            '" class="' + containerClass + '">\n'
        postHtml += avatarImageInPost
        postHtml += '<p class="post-title">' + titleStr + \
            replyAvatarImageInPost + '</p>\n'
        postHtml += contentStr + footerStr + '\n'
        postHtml += '</div>\n'
    else:
        postHtml = galleryStr

    # benchmark 18
    if not allowDownloads:
        timeDiff = int((time.time() - postStartTime) * 1000)
        if timeDiff > 100:
            print('TIMING INDIV ' + boxName + ' 18 = ' + str(timeDiff))

    if not showPublicOnly and storeToCache and \
       boxName != 'tlmedia' and boxName != 'tlbookmarks' and \
       boxName != 'bookmarks':
        saveIndividualPostAsHtmlToCache(baseDir, nickname, domain,
                                        postJsonObject, postHtml)
        updateRecentPostsCache(recentPostsCache, maxRecentPosts,
                               postJsonObject, postHtml)

    # benchmark 19
    if not allowDownloads:
        timeDiff = int((time.time() - postStartTime) * 1000)
        if timeDiff > 100:
            print('TIMING INDIV ' + boxName + ' 19 = ' + str(timeDiff))

    return postHtml


def isQuestion(postObjectJson: {}) -> bool:
    """ is the given post a question?
    """
    if postObjectJson['type'] != 'Create' and \
       postObjectJson['type'] != 'Update':
        return False
    if not isinstance(postObjectJson['object'], dict):
        return False
    if not postObjectJson['object'].get('type'):
        return False
    if postObjectJson['object']['type'] != 'Question':
        return False
    if not postObjectJson['object'].get('oneOf'):
        return False
    if not isinstance(postObjectJson['object']['oneOf'], list):
        return False
    return True


def htmlHighlightLabel(label: str, highlight: bool) -> str:
    """If the give text should be highlighted then return
    the appropriate markup.
    This is so that in shell browsers, like lynx, it's possible
    to see if the replies or DM button are highlighted.
    """
    if not highlight:
        return label
    return '*' + label + '*'


def htmlTimeline(defaultTimeline: str,
                 recentPostsCache: {}, maxRecentPosts: int,
                 translate: {}, pageNumber: int,
                 itemsPerPage: int, session, baseDir: str,
                 wfRequest: {}, personCache: {},
                 nickname: str, domain: str, port: int, timelineJson: {},
                 boxName: str, allowDeletion: bool,
                 httpPrefix: str, projectVersion: str,
                 manuallyApproveFollowers: bool,
                 minimal: bool,
                 YTReplacementDomain: str) -> str:
    """Show the timeline as html
    """
    timelineStartTime = time.time()

    accountDir = baseDir + '/accounts/' + nickname + '@' + domain

    # should the calendar icon be highlighted?
    newCalendarEvent = False
    calendarImage = 'calendar.png'
    calendarPath = '/calendar'
    calendarFile = accountDir + '/.newCalendar'
    if os.path.isfile(calendarFile):
        newCalendarEvent = True
        calendarImage = 'calendar_notify.png'
        with open(calendarFile, 'r') as calfile:
            calendarPath = calfile.read().replace('##sent##', '')
            calendarPath = calendarPath.replace('\n', '').replace('\r', '')

    # should the DM button be highlighted?
    newDM = False
    dmFile = accountDir + '/.newDM'
    if os.path.isfile(dmFile):
        newDM = True
        if boxName == 'dm':
            os.remove(dmFile)

    # should the Replies button be highlighted?
    newReply = False
    replyFile = accountDir + '/.newReply'
    if os.path.isfile(replyFile):
        newReply = True
        if boxName == 'tlreplies':
            os.remove(replyFile)

    # should the Shares button be highlighted?
    newShare = False
    newShareFile = accountDir + '/.newShare'
    if os.path.isfile(newShareFile):
        newShare = True
        if boxName == 'tlshares':
            os.remove(newShareFile)

    # should the Moderation/reports button be highlighted?
    newReport = False
    newReportFile = accountDir + '/.newReport'
    if os.path.isfile(newReportFile):
        newReport = True
        if boxName == 'moderation':
            os.remove(newReportFile)

    # directory where icons are found
    # This changes depending upon theme
    iconsDir = getIconsDir(baseDir)

    # the css filename
    cssFilename = baseDir + '/epicyon-profile.css'
    if os.path.isfile(baseDir + '/epicyon.css'):
        cssFilename = baseDir + '/epicyon.css'

    # filename of the banner shown at the top
    bannerFile = 'banner.png'
    bannerFilename = baseDir + '/accounts/' + \
        nickname + '@' + domain + '/' + bannerFile
    if not os.path.isfile(bannerFilename):
        bannerFile = 'banner.jpg'
        bannerFilename = baseDir + '/accounts/' + \
            nickname + '@' + domain + '/' + bannerFile
    if not os.path.isfile(bannerFilename):
        bannerFile = 'banner.gif'
        bannerFilename = baseDir + '/accounts/' + \
            nickname + '@' + domain + '/' + bannerFile
    if not os.path.isfile(bannerFilename):
        bannerFile = 'banner.webp'

    # benchmark 1
    timeDiff = int((time.time() - timelineStartTime) * 1000)
    if timeDiff > 100:
        print('TIMELINE TIMING ' + boxName + ' 1 = ' + str(timeDiff))

    with open(cssFilename, 'r') as cssFile:
        # load css
        profileStyle = \
            cssFile.read().replace('banner.png',
                                   '/users/' + nickname + '/' + bannerFile)
        # replace any https within the css with whatever prefix is needed
        if httpPrefix != 'https':
            profileStyle = \
                profileStyle.replace('https://',
                                     httpPrefix + '://')

    # is the user a moderator?
    moderator = isModerator(baseDir, nickname)

    # benchmark 2
    timeDiff = int((time.time() - timelineStartTime) * 1000)
    if timeDiff > 100:
        print('TIMELINE TIMING ' + boxName + ' 2 = ' + str(timeDiff))

    # the appearance of buttons - highlighted or not
    inboxButton = 'button'
    blogsButton = 'button'
    dmButton = 'button'
    if newDM:
        dmButton = 'buttonhighlighted'
    repliesButton = 'button'
    if newReply:
        repliesButton = 'buttonhighlighted'
    mediaButton = 'button'
    bookmarksButton = 'button'
    eventsButton = 'button'
    sentButton = 'button'
    sharesButton = 'button'
    if newShare:
        sharesButton = 'buttonhighlighted'
    moderationButton = 'button'
    if newReport:
        moderationButton = 'buttonhighlighted'
    if boxName == 'inbox':
        inboxButton = 'buttonselected'
    elif boxName == 'tlblogs':
        blogsButton = 'buttonselected'
    elif boxName == 'dm':
        dmButton = 'buttonselected'
        if newDM:
            dmButton = 'buttonselectedhighlighted'
    elif boxName == 'tlreplies':
        repliesButton = 'buttonselected'
        if newReply:
            repliesButton = 'buttonselectedhighlighted'
    elif boxName == 'tlmedia':
        mediaButton = 'buttonselected'
    elif boxName == 'outbox':
        sentButton = 'buttonselected'
    elif boxName == 'moderation':
        moderationButton = 'buttonselected'
        if newReport:
            moderationButton = 'buttonselectedhighlighted'
    elif boxName == 'tlshares':
        sharesButton = 'buttonselected'
        if newShare:
            sharesButton = 'buttonselectedhighlighted'
    elif boxName == 'tlbookmarks' or boxName == 'bookmarks':
        bookmarksButton = 'buttonselected'
    elif boxName == 'tlevents':
        eventsButton = 'buttonselected'

    # get the full domain, including any port number
    fullDomain = domain
    if port != 80 and port != 443:
        if ':' not in domain:
            fullDomain = domain + ':' + str(port)

    usersPath = '/users/' + nickname
    actor = httpPrefix + '://' + fullDomain + usersPath

    showIndividualPostIcons = True

    # show an icon for new follow approvals
    followApprovals = ''
    followRequestsFilename = \
        baseDir + '/accounts/' + \
        nickname + '@' + domain + '/followrequests.txt'
    if os.path.isfile(followRequestsFilename):
        with open(followRequestsFilename, 'r') as f:
            for line in f:
                if len(line) > 0:
                    # show follow approvals icon
                    followApprovals = \
                        '<a href="' + usersPath + \
                        '/followers#buttonheader">' + \
                        '<img loading="lazy" ' + \
                        'class="timelineicon" alt="' + \
                        translate['Approve follow requests'] + \
                        '" title="' + translate['Approve follow requests'] + \
                        '" src="/' + iconsDir + '/person.png"/></a>\n'
                    break

    # benchmark 3
    timeDiff = int((time.time() - timelineStartTime) * 1000)
    if timeDiff > 100:
        print('TIMELINE TIMING ' + boxName + ' 3 = ' + str(timeDiff))

    # moderation / reports button
    moderationButtonStr = ''
    if moderator and not minimal:
        moderationButtonStr = \
            '<a href="' + usersPath + \
            '/moderation"><button class="' + \
            moderationButton + '"><span>' + \
            htmlHighlightLabel(translate['Mod'], newReport) + \
            ' </span></button></a>\n'

    # shares, bookmarks and events buttons
    sharesButtonStr = ''
    bookmarksButtonStr = ''
    eventsButtonStr = ''
    if not minimal:
        sharesButtonStr = \
            '<a href="' + usersPath + '/tlshares"><button class="' + \
            sharesButton + '"><span>' + \
            htmlHighlightLabel(translate['Shares'], newShare) + \
            ' </span></button></a>\n'

        bookmarksButtonStr = \
            '<a href="' + usersPath + '/tlbookmarks"><button class="' + \
            bookmarksButton + '"><span>' + translate['Bookmarks'] + \
            ' </span></button></a>\n'

        eventsButtonStr = \
            '<a href="' + usersPath + '/tlevents"><button class="' + \
            eventsButton + '"><span>' + translate['Events'] + \
            ' </span></button></a>\n'

    tlStr = htmlHeader(cssFilename, profileStyle)

    # benchmark 4
    timeDiff = int((time.time() - timelineStartTime) * 1000)
    if timeDiff > 100:
        print('TIMELINE TIMING ' + boxName + ' 4 = ' + str(timeDiff))

    # what screen to go to when a new post is created
    if boxName == 'dm':
        newPostButtonStr = \
            '<a class="imageAnchor" href="' + usersPath + \
            '/newdm"><img loading="lazy" src="/' + \
            iconsDir + '/newpost.png" title="' + \
            translate['Create a new DM'] + \
            '" alt="| ' + translate['Create a new DM'] + \
            '" class="timelineicon"/></a>\n'
    elif boxName == 'tlblogs':
        newPostButtonStr = \
            '<a class="imageAnchor" href="' + usersPath + \
            '/newblog"><img loading="lazy" src="/' + \
            iconsDir + '/newpost.png" title="' + \
            translate['Create a new post'] + '" alt="| ' + \
            translate['Create a new post'] + \
            '" class="timelineicon"/></a>\n'
    elif boxName == 'tlevents':
        newPostButtonStr = \
            '<a class="imageAnchor" href="' + usersPath + \
            '/newevent"><img loading="lazy" src="/' + \
            iconsDir + '/newpost.png" title="' + \
            translate['Create a new event'] + '" alt="| ' + \
            translate['Create a new event'] + \
            '" class="timelineicon"/></a>\n'
    else:
        if not manuallyApproveFollowers:
            newPostButtonStr = \
                '<a class="imageAnchor" href="' + usersPath + \
                '/newpost"><img loading="lazy" src="/' + \
                iconsDir + '/newpost.png" title="' + \
                translate['Create a new post'] + '" alt="| ' + \
                translate['Create a new post'] + \
                '" class="timelineicon"/></a>\n'
        else:
            newPostButtonStr = \
                '<a class="imageAnchor" href="' + usersPath + \
                '/newfollowers"><img loading="lazy" src="/' + \
                iconsDir + '/newpost.png" title="' + \
                translate['Create a new post'] + \
                '" alt="| ' + translate['Create a new post'] + \
                '" class="timelineicon"/></a>\n'

    # This creates a link to the profile page when viewed
    # in lynx, but should be invisible in a graphical web browser
    tlStr += \
        '<a href="/users/' + nickname + '"><label class="transparent">' + \
        translate['Switch to profile view'] + '</label></a>\n'

    # banner and row of buttons
    tlStr += \
        '<a href="/users/' + nickname + '" title="' + \
        translate['Switch to profile view'] + '" alt="' + \
        translate['Switch to profile view'] + '">\n'
    tlStr += '<div class="timeline-banner">'
    tlStr += '</div>\n</a>\n'
    tlStr += '<div class="container">\n'

    # first button
    if defaultTimeline == 'tlmedia':
        tlStr += \
            '    <a href="' + usersPath + \
            '/tlmedia"><button class="' + \
            mediaButton + '"><span>' + translate['Media'] + \
            '</span></button></a>\n'
    elif defaultTimeline == 'tlblogs':
        tlStr += \
            '    <a href="' + usersPath + \
            '/tlblogs"><button class="' + \
            blogsButton + '"><span>' + translate['Blogs'] + \
            '</span></button></a>\n'
    else:
        tlStr += \
            '    <a href="' + usersPath + \
            '/inbox"><button class="' + \
            inboxButton + '"><span>' + \
            translate['Inbox'] + '</span></button></a>\n'

    tlStr += \
        '    <a href="' + usersPath + '/dm"><button class="' + dmButton + \
        '"><span>' + htmlHighlightLabel(translate['DM'], newDM) + \
        '</span></button></a>\n'

    tlStr += \
        '    <a href="' + usersPath + '/tlreplies"><button class="' + \
        repliesButton + '"><span>' + \
        htmlHighlightLabel(translate['Replies'], newReply) + \
        '</span></button></a>\n'

    # typically the media button
    if defaultTimeline != 'tlmedia':
        if not minimal:
            tlStr += \
                '    <a href="' + usersPath + \
                '/tlmedia"><button class="' + \
                mediaButton + '"><span>' + translate['Media'] + \
                '</span></button></a>\n'
    else:
        if not minimal:
            tlStr += \
                '    <a href="' + usersPath + \
                '/inbox"><button class="' + \
                inboxButton+'"><span>' + translate['Inbox'] + \
                '</span></button></a>\n'

    # typically the blogs button
    # but may change if this is a blogging oriented instance
    if defaultTimeline != 'tlblogs':
        if not minimal:
            tlStr += \
                '    <a href="' + usersPath + \
                '/tlblogs"><button class="' + \
                blogsButton + '"><span>' + translate['Blogs'] + \
                '</span></button></a>\n'
    else:
        if not minimal:
            tlStr += \
                '    <a href="' + usersPath + \
                '/inbox"><button class="' + \
                inboxButton + '"><span>' + translate['Inbox'] + \
                '</span></button></a>\n'

    # button for the outbox
    tlStr += \
        '    <a href="' + usersPath + \
        '/outbox"><button class="' + \
        sentButton+'"><span>' + translate['Outbox'] + \
        '</span></button></a>\n'

    # add other buttons
    tlStr += \
        sharesButtonStr + bookmarksButtonStr + eventsButtonStr + \
        moderationButtonStr + newPostButtonStr

    # the search button
    tlStr += \
        '    <a class="imageAnchor" href="' + usersPath + \
        '/search"><img loading="lazy" src="/' + \
        iconsDir + '/search.png" title="' + \
        translate['Search and follow'] + '" alt="| ' + \
        translate['Search and follow'] + '" class="timelineicon"/></a>\n'

    # benchmark 5
    timeDiff = int((time.time() - timelineStartTime) * 1000)
    if timeDiff > 100:
        print('TIMELINE TIMING ' + boxName + ' 5 = ' + str(timeDiff))

    # the calendar button
    calendarAltText = translate['Calendar']
    if newCalendarEvent:
        # indicate that the calendar icon is highlighted
        calendarAltText = '*' + calendarAltText + '*'
    tlStr += \
        '    <a class="imageAnchor" href="' + usersPath + calendarPath + \
        '"><img loading="lazy" src="/' + iconsDir + '/' + \
        calendarImage + '" title="' + translate['Calendar'] + \
        '" alt="| ' + calendarAltText + '" class="timelineicon"/></a>\n'

    # the show/hide button, for a simpler header appearance
    tlStr += \
        '    <a class="imageAnchor" href="' + usersPath + '/minimal' + \
        '"><img loading="lazy" src="/' + iconsDir + \
        '/showhide.png" title="' + translate['Show/Hide Buttons'] + \
        '" alt="| ' + translate['Show/Hide Buttons'] + \
        '" class="timelineicon"/></a>\n'
    tlStr += followApprovals
    tlStr += '</div>'

    # second row of buttons for moderator actions
    if moderator and boxName == 'moderation':
        tlStr += \
            '<form method="POST" action="/users/' + \
            nickname + '/moderationaction">'
        tlStr += '<div class="container">\n'
        idx = 'Nickname or URL. Block using *@domain or nickname@domain'
        tlStr += \
            '    <b>' + translate[idx] + '</b><br>\n'
        tlStr += '    <input type="text" ' + \
            'name="moderationAction" value="" autofocus><br>\n'
        tlStr += \
            '    <input type="submit" title="' + \
            translate['Remove the above item'] + \
            '" name="submitRemove" value="' + \
            translate['Remove'] + '">\n'
        tlStr += \
            '    <input type="submit" title="' + \
            translate['Suspend the above account nickname'] + \
            '" name="submitSuspend" value="' + translate['Suspend'] + '">\n'
        tlStr += \
            '    <input type="submit" title="' + \
            translate['Remove a suspension for an account nickname'] + \
            '" name="submitUnsuspend" value="' + \
            translate['Unsuspend'] + '">\n'
        tlStr += \
            '    <input type="submit" title="' + \
            translate['Block an account on another instance'] + \
            '" name="submitBlock" value="' + translate['Block'] + '">\n'
        tlStr += \
            '    <input type="submit" title="' + \
            translate['Unblock an account on another instance'] + \
            '" name="submitUnblock" value="' + translate['Unblock'] + '">\n'
        tlStr += \
            '    <input type="submit" title="' + \
            translate['Information about current blocks/suspensions'] + \
            '" name="submitInfo" value="' + translate['Info'] + '">\n'
        tlStr += '</div>\n</form>\n'

    # benchmark 6
    timeDiff = int((time.time() - timelineStartTime) * 1000)
    if timeDiff > 100:
        print('TIMELINE TIMING ' + boxName + ' 6 = ' + str(timeDiff))

    if boxName == 'tlshares':
        maxSharesPerAccount = itemsPerPage
        return (tlStr +
                htmlSharesTimeline(translate, pageNumber, itemsPerPage,
                                   baseDir, actor, nickname, domain, port,
                                   maxSharesPerAccount, httpPrefix) +
                htmlFooter())

    # benchmark 7
    timeDiff = int((time.time() - timelineStartTime) * 1000)
    if timeDiff > 100:
        print('TIMELINE TIMING ' + boxName + ' 7 = ' + str(timeDiff))

    # show todays events buttons on the first inbox page
    if boxName == 'inbox' and pageNumber == 1:
        if todaysEventsCheck(baseDir, nickname, domain):
            now = datetime.now()

            # happening today button
            tlStr += \
                '<center>\n<a href="' + usersPath + '/calendar?year=' + \
                str(now.year) + '?month=' + str(now.month) + \
                '?day=' + str(now.day) + '"><button class="buttonevent">' + \
                translate['Happening Today'] + '</button></a>\n'

            # happening this week button
            if thisWeeksEventsCheck(baseDir, nickname, domain):
                tlStr += \
                    '<a href="' + usersPath + \
                    '/calendar"><button class="buttonevent">' + \
                    translate['Happening This Week'] + '</button></a>\n'
            tlStr += '</center>\n'
        else:
            # happening this week button
            if thisWeeksEventsCheck(baseDir, nickname, domain):
                tlStr += \
                    '<center>\n<a href="' + usersPath + \
                    '/calendar"><button class="buttonevent">' + \
                    translate['Happening This Week'] + '</button></a>\n' + \
                    '</center>\n'

    # benchmark 8
    timeDiff = int((time.time() - timelineStartTime) * 1000)
    if timeDiff > 100:
        print('TIMELINE TIMING ' + boxName + ' 8 = ' + str(timeDiff))

    # page up arrow
    if pageNumber > 1:
        tlStr += \
            '<center>\n<a href="' + usersPath + '/' + boxName + \
            '?page=' + str(pageNumber - 1) + \
            '"><img loading="lazy" class="pageicon" src="/' + \
            iconsDir + '/pageup.png" title="' + \
            translate['Page up'] + '" alt="' + \
            translate['Page up'] + '"></a>\n</center>\n'

    # show the posts
    itemCtr = 0
    if timelineJson:
        # if this is the media timeline then add an extra gallery container
        if boxName == 'tlmedia':
            if pageNumber > 1:
                tlStr += '<br>'
            tlStr += '<div class="galleryContainer">\n'

        # show each post in the timeline
        for item in timelineJson['orderedItems']:
            timelinePostStartTime = time.time()

            if item['type'] == 'Create' or \
               item['type'] == 'Announce' or \
               item['type'] == 'Update':
                # is the actor who sent this post snoozed?
                if isPersonSnoozed(baseDir, nickname, domain, item['actor']):
                    continue

                # is the post in the memory cache of recent ones?
                currTlStr = None
                if boxName != 'tlmedia' and \
                   recentPostsCache.get('index'):
                    postId = \
                        removeIdEnding(item['id']).replace('/', '#')
                    if postId in recentPostsCache['index']:
                        if not item.get('muted'):
                            if recentPostsCache['html'].get(postId):
                                currTlStr = recentPostsCache['html'][postId]
                                currTlStr = \
                                    preparePostFromHtmlCache(currTlStr,
                                                             boxName,
                                                             pageNumber)
                                # benchmark cache post
                                timeDiff = \
                                    int((time.time() -
                                         timelinePostStartTime) * 1000)
                                if timeDiff > 100:
                                    print('TIMELINE POST CACHE TIMING ' +
                                          boxName + ' = ' + str(timeDiff))

                if not currTlStr:
                    # benchmark cache post
                    timeDiff = \
                        int((time.time() -
                             timelinePostStartTime) * 1000)
                    if timeDiff > 100:
                        print('TIMELINE POST DISK TIMING START ' +
                              boxName + ' = ' + str(timeDiff))

                    # read the post from disk
                    currTlStr = \
                        individualPostAsHtml(False, recentPostsCache,
                                             maxRecentPosts,
                                             iconsDir, translate, pageNumber,
                                             baseDir, session, wfRequest,
                                             personCache,
                                             nickname, domain, port,
                                             item, None, True,
                                             allowDeletion,
                                             httpPrefix, projectVersion,
                                             boxName,
                                             YTReplacementDomain,
                                             boxName != 'dm',
                                             showIndividualPostIcons,
                                             manuallyApproveFollowers,
                                             False, True)
                    # benchmark cache post
                    timeDiff = \
                        int((time.time() -
                             timelinePostStartTime) * 1000)
                    if timeDiff > 100:
                        print('TIMELINE POST DISK TIMING ' +
                              boxName + ' = ' + str(timeDiff))

                if currTlStr:
                    itemCtr += 1
                    tlStr += currTlStr
        if boxName == 'tlmedia':
            tlStr += '</div>\n'

    # benchmark 9
    timeDiff = int((time.time() - timelineStartTime) * 1000)
    if timeDiff > 100:
        print('TIMELINE TIMING ' + boxName + ' 9 = ' + str(timeDiff))

    # page down arrow
    if itemCtr > 2:
        tlStr += \
            '<center>\n<a href="' + usersPath + '/' + boxName + '?page=' + \
            str(pageNumber + 1) + \
            '"><img loading="lazy" class="pageicon" src="/' + \
            iconsDir + '/pagedown.png" title="' + \
            translate['Page down'] + '" alt="' + \
            translate['Page down'] + '"></a>\n</center>\n'
    tlStr += htmlFooter()
    return tlStr


def htmlShares(defaultTimeline: str,
               recentPostsCache: {}, maxRecentPosts: int,
               translate: {}, pageNumber: int, itemsPerPage: int,
               session, baseDir: str, wfRequest: {}, personCache: {},
               nickname: str, domain: str, port: int,
               allowDeletion: bool,
               httpPrefix: str, projectVersion: str,
               YTReplacementDomain: str) -> str:
    """Show the shares timeline as html
    """
    manuallyApproveFollowers = \
        followerApprovalActive(baseDir, nickname, domain)

    return htmlTimeline(defaultTimeline, recentPostsCache, maxRecentPosts,
                        translate, pageNumber,
                        itemsPerPage, session, baseDir, wfRequest, personCache,
                        nickname, domain, port, None,
                        'tlshares', allowDeletion,
                        httpPrefix, projectVersion, manuallyApproveFollowers,
                        False, YTReplacementDomain)


def htmlInbox(defaultTimeline: str,
              recentPostsCache: {}, maxRecentPosts: int,
              translate: {}, pageNumber: int, itemsPerPage: int,
              session, baseDir: str, wfRequest: {}, personCache: {},
              nickname: str, domain: str, port: int, inboxJson: {},
              allowDeletion: bool,
              httpPrefix: str, projectVersion: str,
              minimal: bool, YTReplacementDomain: str) -> str:
    """Show the inbox as html
    """
    manuallyApproveFollowers = \
        followerApprovalActive(baseDir, nickname, domain)

    return htmlTimeline(defaultTimeline, recentPostsCache, maxRecentPosts,
                        translate, pageNumber,
                        itemsPerPage, session, baseDir, wfRequest, personCache,
                        nickname, domain, port, inboxJson,
                        'inbox', allowDeletion,
                        httpPrefix, projectVersion, manuallyApproveFollowers,
                        minimal, YTReplacementDomain)


def htmlBookmarks(defaultTimeline: str,
                  recentPostsCache: {}, maxRecentPosts: int,
                  translate: {}, pageNumber: int, itemsPerPage: int,
                  session, baseDir: str, wfRequest: {}, personCache: {},
                  nickname: str, domain: str, port: int, bookmarksJson: {},
                  allowDeletion: bool,
                  httpPrefix: str, projectVersion: str,
                  minimal: bool, YTReplacementDomain: str) -> str:
    """Show the bookmarks as html
    """
    manuallyApproveFollowers = \
        followerApprovalActive(baseDir, nickname, domain)

    return htmlTimeline(defaultTimeline, recentPostsCache, maxRecentPosts,
                        translate, pageNumber,
                        itemsPerPage, session, baseDir, wfRequest, personCache,
                        nickname, domain, port, bookmarksJson,
                        'tlbookmarks', allowDeletion,
                        httpPrefix, projectVersion, manuallyApproveFollowers,
                        minimal, YTReplacementDomain)


def htmlEvents(defaultTimeline: str,
               recentPostsCache: {}, maxRecentPosts: int,
               translate: {}, pageNumber: int, itemsPerPage: int,
               session, baseDir: str, wfRequest: {}, personCache: {},
               nickname: str, domain: str, port: int, bookmarksJson: {},
               allowDeletion: bool,
               httpPrefix: str, projectVersion: str,
               minimal: bool, YTReplacementDomain: str) -> str:
    """Show the events as html
    """
    manuallyApproveFollowers = \
        followerApprovalActive(baseDir, nickname, domain)

    return htmlTimeline(defaultTimeline, recentPostsCache, maxRecentPosts,
                        translate, pageNumber,
                        itemsPerPage, session, baseDir, wfRequest, personCache,
                        nickname, domain, port, bookmarksJson,
                        'tlevents', allowDeletion,
                        httpPrefix, projectVersion, manuallyApproveFollowers,
                        minimal, YTReplacementDomain)


def htmlInboxDMs(defaultTimeline: str,
                 recentPostsCache: {}, maxRecentPosts: int,
                 translate: {}, pageNumber: int, itemsPerPage: int,
                 session, baseDir: str, wfRequest: {}, personCache: {},
                 nickname: str, domain: str, port: int, inboxJson: {},
                 allowDeletion: bool,
                 httpPrefix: str, projectVersion: str,
                 minimal: bool, YTReplacementDomain: str) -> str:
    """Show the DM timeline as html
    """
    return htmlTimeline(defaultTimeline, recentPostsCache, maxRecentPosts,
                        translate, pageNumber,
                        itemsPerPage, session, baseDir, wfRequest, personCache,
                        nickname, domain, port, inboxJson, 'dm', allowDeletion,
                        httpPrefix, projectVersion, False, minimal,
                        YTReplacementDomain)


def htmlInboxReplies(defaultTimeline: str,
                     recentPostsCache: {}, maxRecentPosts: int,
                     translate: {}, pageNumber: int, itemsPerPage: int,
                     session, baseDir: str, wfRequest: {}, personCache: {},
                     nickname: str, domain: str, port: int, inboxJson: {},
                     allowDeletion: bool,
                     httpPrefix: str, projectVersion: str,
                     minimal: bool, YTReplacementDomain: str) -> str:
    """Show the replies timeline as html
    """
    return htmlTimeline(defaultTimeline, recentPostsCache, maxRecentPosts,
                        translate, pageNumber,
                        itemsPerPage, session, baseDir, wfRequest, personCache,
                        nickname, domain, port, inboxJson, 'tlreplies',
                        allowDeletion, httpPrefix, projectVersion, False,
                        minimal, YTReplacementDomain)


def htmlInboxMedia(defaultTimeline: str,
                   recentPostsCache: {}, maxRecentPosts: int,
                   translate: {}, pageNumber: int, itemsPerPage: int,
                   session, baseDir: str, wfRequest: {}, personCache: {},
                   nickname: str, domain: str, port: int, inboxJson: {},
                   allowDeletion: bool,
                   httpPrefix: str, projectVersion: str,
                   minimal: bool, YTReplacementDomain: str) -> str:
    """Show the media timeline as html
    """
    return htmlTimeline(defaultTimeline, recentPostsCache, maxRecentPosts,
                        translate, pageNumber,
                        itemsPerPage, session, baseDir, wfRequest, personCache,
                        nickname, domain, port, inboxJson, 'tlmedia',
                        allowDeletion, httpPrefix, projectVersion, False,
                        minimal, YTReplacementDomain)


def htmlInboxBlogs(defaultTimeline: str,
                   recentPostsCache: {}, maxRecentPosts: int,
                   translate: {}, pageNumber: int, itemsPerPage: int,
                   session, baseDir: str, wfRequest: {}, personCache: {},
                   nickname: str, domain: str, port: int, inboxJson: {},
                   allowDeletion: bool,
                   httpPrefix: str, projectVersion: str,
                   minimal: bool, YTReplacementDomain: str) -> str:
    """Show the blogs timeline as html
    """
    return htmlTimeline(defaultTimeline, recentPostsCache, maxRecentPosts,
                        translate, pageNumber,
                        itemsPerPage, session, baseDir, wfRequest, personCache,
                        nickname, domain, port, inboxJson, 'tlblogs',
                        allowDeletion, httpPrefix, projectVersion, False,
                        minimal, YTReplacementDomain)


def htmlModeration(defaultTimeline: str,
                   recentPostsCache: {}, maxRecentPosts: int,
                   translate: {}, pageNumber: int, itemsPerPage: int,
                   session, baseDir: str, wfRequest: {}, personCache: {},
                   nickname: str, domain: str, port: int, inboxJson: {},
                   allowDeletion: bool,
                   httpPrefix: str, projectVersion: str,
                   YTReplacementDomain: str) -> str:
    """Show the moderation feed as html
    """
    return htmlTimeline(defaultTimeline, recentPostsCache, maxRecentPosts,
                        translate, pageNumber,
                        itemsPerPage, session, baseDir, wfRequest, personCache,
                        nickname, domain, port, inboxJson, 'moderation',
                        allowDeletion, httpPrefix, projectVersion, True, False,
                        YTReplacementDomain)


def htmlOutbox(defaultTimeline: str,
               recentPostsCache: {}, maxRecentPosts: int,
               translate: {}, pageNumber: int, itemsPerPage: int,
               session, baseDir: str, wfRequest: {}, personCache: {},
               nickname: str, domain: str, port: int, outboxJson: {},
               allowDeletion: bool,
               httpPrefix: str, projectVersion: str,
               minimal: bool, YTReplacementDomain: str) -> str:
    """Show the Outbox as html
    """
    manuallyApproveFollowers = \
        followerApprovalActive(baseDir, nickname, domain)
    return htmlTimeline(defaultTimeline, recentPostsCache, maxRecentPosts,
                        translate, pageNumber,
                        itemsPerPage, session, baseDir, wfRequest, personCache,
                        nickname, domain, port, outboxJson, 'outbox',
                        allowDeletion, httpPrefix, projectVersion,
                        manuallyApproveFollowers, minimal,
                        YTReplacementDomain)


def htmlIndividualPost(recentPostsCache: {}, maxRecentPosts: int,
                       translate: {},
                       baseDir: str, session, wfRequest: {}, personCache: {},
                       nickname: str, domain: str, port: int, authorized: bool,
                       postJsonObject: {}, httpPrefix: str,
                       projectVersion: str, likedBy: str,
                       YTReplacementDomain: str) -> str:
    """Show an individual post as html
    """
    iconsDir = getIconsDir(baseDir)
    postStr = ''
    if likedBy:
        likedByNickname = getNicknameFromActor(likedBy)
        likedByDomain, likedByPort = getDomainFromActor(likedBy)
        if likedByPort:
            if likedByPort != 80 and likedByPort != 443:
                likedByDomain += ':' + str(likedByPort)
        likedByHandle = likedByNickname + '@' + likedByDomain
        postStr += \
            '<p>' + translate['Liked by'] + \
            ' <a href="' + likedBy + '">@' + \
            likedByHandle + '</a>\n'

        domainFull = domain
        if port:
            if port != 80 and port != 443:
                domainFull = domain + ':' + str(port)
        actor = '/users/' + nickname
        followStr = '  <form method="POST" ' + \
            'accept-charset="UTF-8" action="' + actor + '/searchhandle">\n'
        followStr += \
            '    <input type="hidden" name="actor" value="' + actor + '">\n'
        followStr += \
            '    <input type="hidden" name="searchtext" value="' + \
            likedByHandle + '">\n'
        if not isFollowingActor(baseDir, nickname, domainFull, likedBy):
            followStr += '    <button type="submit" class="button" ' + \
                'name="submitSearch">' + translate['Follow'] + '</button>\n'
        followStr += '    <button type="submit" class="button" ' + \
            'name="submitBack">' + translate['Go Back'] + '</button>\n'
        followStr += '  </form>\n'
        postStr += followStr + '</p>\n'

    postStr += \
        individualPostAsHtml(True, recentPostsCache, maxRecentPosts,
                             iconsDir, translate, None,
                             baseDir, session, wfRequest, personCache,
                             nickname, domain, port, postJsonObject,
                             None, True, False,
                             httpPrefix, projectVersion, 'inbox',
                             YTReplacementDomain,
                             False, authorized, False, False, False)
    messageId = removeIdEnding(postJsonObject['id'])

    # show the previous posts
    if isinstance(postJsonObject['object'], dict):
        while postJsonObject['object'].get('inReplyTo'):
            postFilename = \
                locatePost(baseDir, nickname, domain,
                           postJsonObject['object']['inReplyTo'])
            if not postFilename:
                break
            postJsonObject = loadJson(postFilename)
            if postJsonObject:
                postStr = \
                    individualPostAsHtml(True, recentPostsCache,
                                         maxRecentPosts,
                                         iconsDir, translate, None,
                                         baseDir, session, wfRequest,
                                         personCache,
                                         nickname, domain, port,
                                         postJsonObject,
                                         None, True, False,
                                         httpPrefix, projectVersion, 'inbox',
                                         YTReplacementDomain,
                                         False, authorized,
                                         False, False, False) + postStr

    # show the following posts
    postFilename = locatePost(baseDir, nickname, domain, messageId)
    if postFilename:
        # is there a replies file for this post?
        repliesFilename = postFilename.replace('.json', '.replies')
        if os.path.isfile(repliesFilename):
            # get items from the replies file
            repliesJson = {
                'orderedItems': []
            }
            populateRepliesJson(baseDir, nickname, domain,
                                repliesFilename, authorized, repliesJson)
            # add items to the html output
            for item in repliesJson['orderedItems']:
                postStr += \
                    individualPostAsHtml(True, recentPostsCache,
                                         maxRecentPosts,
                                         iconsDir, translate, None,
                                         baseDir, session, wfRequest,
                                         personCache,
                                         nickname, domain, port, item,
                                         None, True, False,
                                         httpPrefix, projectVersion, 'inbox',
                                         YTReplacementDomain,
                                         False, authorized,
                                         False, False, False)
    cssFilename = baseDir + '/epicyon-profile.css'
    if os.path.isfile(baseDir + '/epicyon.css'):
        cssFilename = baseDir + '/epicyon.css'
    with open(cssFilename, 'r') as cssFile:
        postsCSS = cssFile.read()
        if httpPrefix != 'https':
            postsCSS = postsCSS.replace('https://',
                                        httpPrefix + '://')
    return htmlHeader(cssFilename, postsCSS) + postStr + htmlFooter()


def htmlPostReplies(recentPostsCache: {}, maxRecentPosts: int,
                    translate: {}, baseDir: str,
                    session, wfRequest: {}, personCache: {},
                    nickname: str, domain: str, port: int, repliesJson: {},
                    httpPrefix: str, projectVersion: str,
                    YTReplacementDomain: str) -> str:
    """Show the replies to an individual post as html
    """
    iconsDir = getIconsDir(baseDir)
    repliesStr = ''
    if repliesJson.get('orderedItems'):
        for item in repliesJson['orderedItems']:
            repliesStr += \
                individualPostAsHtml(True, recentPostsCache,
                                     maxRecentPosts,
                                     iconsDir, translate, None,
                                     baseDir, session, wfRequest, personCache,
                                     nickname, domain, port, item,
                                     None, True, False,
                                     httpPrefix, projectVersion, 'inbox',
                                     YTReplacementDomain,
                                     False, False, False, False, False)

    cssFilename = baseDir + '/epicyon-profile.css'
    if os.path.isfile(baseDir + '/epicyon.css'):
        cssFilename = baseDir + '/epicyon.css'
    with open(cssFilename, 'r') as cssFile:
        postsCSS = cssFile.read()
        if httpPrefix != 'https':
            postsCSS = postsCSS.replace('https://',
                                        httpPrefix + '://')
    return htmlHeader(cssFilename, postsCSS) + repliesStr + htmlFooter()


def htmlRemoveSharedItem(translate: {}, baseDir: str,
                         actor: str, shareName: str,
                         callingDomain: str) -> str:
    """Shows a screen asking to confirm the removal of a shared item
    """
    itemID = getValidSharedItemID(shareName)
    nickname = getNicknameFromActor(actor)
    domain, port = getDomainFromActor(actor)
    domainFull = domain
    if port:
        if port != 80 and port != 443:
            domainFull = domain + ':' + str(port)
    sharesFile = baseDir + '/accounts/' + \
        nickname + '@' + domain + '/shares.json'
    if not os.path.isfile(sharesFile):
        print('ERROR: no shares file ' + sharesFile)
        return None
    sharesJson = loadJson(sharesFile)
    if not sharesJson:
        print('ERROR: unable to load shares.json')
        return None
    if not sharesJson.get(itemID):
        print('ERROR: share named "' + itemID + '" is not in ' + sharesFile)
        return None
    sharedItemDisplayName = sharesJson[itemID]['displayName']
    sharedItemImageUrl = None
    if sharesJson[itemID].get('imageUrl'):
        sharedItemImageUrl = sharesJson[itemID]['imageUrl']

    if os.path.isfile(baseDir + '/img/shares-background.png'):
        if not os.path.isfile(baseDir + '/accounts/shares-background.png'):
            copyfile(baseDir + '/img/shares-background.png',
                     baseDir + '/accounts/shares-background.png')

    cssFilename = baseDir + '/epicyon-follow.css'
    if os.path.isfile(baseDir + '/follow.css'):
        cssFilename = baseDir + '/follow.css'
    with open(cssFilename, 'r') as cssFile:
        profileStyle = cssFile.read()
    sharesStr = htmlHeader(cssFilename, profileStyle)
    sharesStr += '<div class="follow">\n'
    sharesStr += '  <div class="followAvatar">\n'
    sharesStr += '  <center>\n'
    if sharedItemImageUrl:
        sharesStr += '  <img loading="lazy" src="' + \
            sharedItemImageUrl + '"/>\n'
    sharesStr += \
        '  <p class="followText">' + translate['Remove'] + \
        ' ' + sharedItemDisplayName + ' ?</p>\n'
    postActor = getAltPath(actor, domainFull, callingDomain)
    sharesStr += '  <form method="POST" action="' + postActor + '/rmshare">\n'
    sharesStr += \
        '    <input type="hidden" name="actor" value="' + actor + '">\n'
    sharesStr += '    <input type="hidden" name="shareName" value="' + \
        shareName + '">\n'
    sharesStr += \
        '    <button type="submit" class="button" name="submitYes">' + \
        translate['Yes'] + '</button>\n'
    sharesStr += \
        '    <a href="' + actor + '/inbox' + '"><button class="button">' + \
        translate['No'] + '</button></a>\n'
    sharesStr += '  </form>\n'
    sharesStr += '  </center>\n'
    sharesStr += '  </div>\n'
    sharesStr += '</div>\n'
    sharesStr += htmlFooter()
    return sharesStr


def htmlDeletePost(recentPostsCache: {}, maxRecentPosts: int,
                   translate, pageNumber: int,
                   session, baseDir: str, messageId: str,
                   httpPrefix: str, projectVersion: str,
                   wfRequest: {}, personCache: {},
                   callingDomain: str,
                   YTReplacementDomain: str) -> str:
    """Shows a screen asking to confirm the deletion of a post
    """
    if '/statuses/' not in messageId:
        return None
    iconsDir = getIconsDir(baseDir)
    actor = messageId.split('/statuses/')[0]
    nickname = getNicknameFromActor(actor)
    domain, port = getDomainFromActor(actor)
    domainFull = domain
    if port:
        if port != 80 and port != 443:
            domainFull = domain + ':' + str(port)

    postFilename = locatePost(baseDir, nickname, domain, messageId)
    if not postFilename:
        return None

    postJsonObject = loadJson(postFilename)
    if not postJsonObject:
        return None

    if os.path.isfile(baseDir + '/img/delete-background.png'):
        if not os.path.isfile(baseDir + '/accounts/delete-background.png'):
            copyfile(baseDir + '/img/delete-background.png',
                     baseDir + '/accounts/delete-background.png')

    deletePostStr = None
    cssFilename = baseDir + '/epicyon-profile.css'
    if os.path.isfile(baseDir + '/epicyon.css'):
        cssFilename = baseDir + '/epicyon.css'
    with open(cssFilename, 'r') as cssFile:
        profileStyle = cssFile.read()
        if httpPrefix != 'https':
            profileStyle = profileStyle.replace('https://',
                                                httpPrefix + '://')
        deletePostStr = htmlHeader(cssFilename, profileStyle)
        deletePostStr += \
            individualPostAsHtml(True, recentPostsCache, maxRecentPosts,
                                 iconsDir, translate, pageNumber,
                                 baseDir, session, wfRequest, personCache,
                                 nickname, domain, port, postJsonObject,
                                 None, True, False,
                                 httpPrefix, projectVersion, 'outbox',
                                 YTReplacementDomain,
                                 False, False, False, False, False)
        deletePostStr += '<center>'
        deletePostStr += \
            '  <p class="followText">' + \
            translate['Delete this post?'] + '</p>'

        postActor = getAltPath(actor, domainFull, callingDomain)
        deletePostStr += \
            '  <form method="POST" action="' + postActor + '/rmpost">\n'
        deletePostStr += \
            '    <input type="hidden" name="pageNumber" value="' + \
            str(pageNumber) + '">\n'
        deletePostStr += \
            '    <input type="hidden" name="messageId" value="' + \
            messageId + '">\n'
        deletePostStr += \
            '    <button type="submit" class="button" name="submitYes">' + \
            translate['Yes'] + '</button>\n'
        deletePostStr += \
            '    <a href="' + actor + '/inbox"><button class="button">' + \
            translate['No'] + '</button></a>\n'
        deletePostStr += '  </form>\n'
        deletePostStr += '</center>\n'
        deletePostStr += htmlFooter()
    return deletePostStr


def htmlCalendarDeleteConfirm(translate: {}, baseDir: str,
                              path: str, httpPrefix: str,
                              domainFull: str, postId: str, postTime: str,
                              year: int, monthNumber: int,
                              dayNumber: int, callingDomain: str) -> str:
    """Shows a screen asking to confirm the deletion of a calendar event
    """
    nickname = getNicknameFromActor(path)
    actor = httpPrefix + '://' + domainFull + '/users/' + nickname
    domain, port = getDomainFromActor(actor)
    messageId = actor + '/statuses/' + postId

    postFilename = locatePost(baseDir, nickname, domain, messageId)
    if not postFilename:
        return None

    postJsonObject = loadJson(postFilename)
    if not postJsonObject:
        return None

    if os.path.isfile(baseDir + '/img/delete-background.png'):
        if not os.path.isfile(baseDir + '/accounts/delete-background.png'):
            copyfile(baseDir + '/img/delete-background.png',
                     baseDir + '/accounts/delete-background.png')

    deletePostStr = None
    cssFilename = baseDir + '/epicyon-profile.css'
    if os.path.isfile(baseDir + '/epicyon.css'):
        cssFilename = baseDir + '/epicyon.css'
    with open(cssFilename, 'r') as cssFile:
        profileStyle = cssFile.read()
        if httpPrefix != 'https':
            profileStyle = profileStyle.replace('https://',
                                                httpPrefix + '://')
        deletePostStr = htmlHeader(cssFilename, profileStyle)
        deletePostStr += \
            '<center><h1>' + postTime + ' ' + str(year) + '/' + \
            str(monthNumber) + \
            '/' + str(dayNumber) + '</h1></center>'
        deletePostStr += '<center>'
        deletePostStr += '  <p class="followText">' + \
            translate['Delete this event'] + '</p>'

        postActor = getAltPath(actor, domainFull, callingDomain)
        deletePostStr += \
            '  <form method="POST" action="' + postActor + '/rmpost">\n'
        deletePostStr += '    <input type="hidden" name="year" value="' + \
            str(year) + '">\n'
        deletePostStr += '    <input type="hidden" name="month" value="' + \
            str(monthNumber) + '">\n'
        deletePostStr += '    <input type="hidden" name="day" value="' + \
            str(dayNumber) + '">\n'
        deletePostStr += \
            '    <input type="hidden" name="pageNumber" value="1">\n'
        deletePostStr += \
            '    <input type="hidden" name="messageId" value="' + \
            messageId + '">\n'
        deletePostStr += \
            '    <button type="submit" class="button" name="submitYes">' + \
            translate['Yes'] + '</button>\n'
        deletePostStr += \
            '    <a href="' + actor + '/calendar?year=' + \
            str(year) + '?month=' + \
            str(monthNumber) + '"><button class="button">' + \
            translate['No'] + '</button></a>\n'
        deletePostStr += '  </form>\n'
        deletePostStr += '</center>\n'
        deletePostStr += htmlFooter()
    return deletePostStr


def htmlFollowConfirm(translate: {}, baseDir: str,
                      originPathStr: str,
                      followActor: str,
                      followProfileUrl: str) -> str:
    """Asks to confirm a follow
    """
    followDomain, port = getDomainFromActor(followActor)

    if os.path.isfile(baseDir + '/accounts/follow-background-custom.jpg'):
        if not os.path.isfile(baseDir + '/accounts/follow-background.jpg'):
            copyfile(baseDir + '/accounts/follow-background-custom.jpg',
                     baseDir + '/accounts/follow-background.jpg')

    cssFilename = baseDir + '/epicyon-follow.css'
    if os.path.isfile(baseDir + '/follow.css'):
        cssFilename = baseDir + '/follow.css'
    with open(cssFilename, 'r') as cssFile:
        profileStyle = cssFile.read()
    followStr = htmlHeader(cssFilename, profileStyle)
    followStr += '<div class="follow">\n'
    followStr += '  <div class="followAvatar">\n'
    followStr += '  <center>\n'
    followStr += '  <a href="' + followActor + '">\n'
    followStr += '  <img loading="lazy" src="' + followProfileUrl + '"/></a>\n'
    followStr += \
        '  <p class="followText">' + translate['Follow'] + ' ' + \
        getNicknameFromActor(followActor) + '@' + followDomain + ' ?</p>\n'
    followStr += '  <form method="POST" action="' + \
        originPathStr + '/followconfirm">\n'
    followStr += '    <input type="hidden" name="actor" value="' + \
        followActor + '">\n'
    followStr += \
        '    <button type="submit" class="button" name="submitYes">' + \
        translate['Yes'] + '</button>\n'
    followStr += \
        '    <a href="' + originPathStr + '"><button class="button">' + \
        translate['No'] + '</button></a>\n'
    followStr += '  </form>\n'
    followStr += '</center>\n'
    followStr += '</div>\n'
    followStr += '</div>\n'
    followStr += htmlFooter()
    return followStr


def htmlUnfollowConfirm(translate: {}, baseDir: str,
                        originPathStr: str,
                        followActor: str,
                        followProfileUrl: str) -> str:
    """Asks to confirm unfollowing an actor
    """
    followDomain, port = getDomainFromActor(followActor)

    if os.path.isfile(baseDir + '/accounts/follow-background-custom.jpg'):
        if not os.path.isfile(baseDir + '/accounts/follow-background.jpg'):
            copyfile(baseDir + '/accounts/follow-background-custom.jpg',
                     baseDir + '/accounts/follow-background.jpg')

    cssFilename = baseDir + '/epicyon-follow.css'
    if os.path.isfile(baseDir + '/follow.css'):
        cssFilename = baseDir + '/follow.css'
    with open(cssFilename, 'r') as cssFile:
        profileStyle = cssFile.read()
    followStr = htmlHeader(cssFilename, profileStyle)
    followStr += '<div class="follow">\n'
    followStr += '  <div class="followAvatar">\n'
    followStr += '  <center>\n'
    followStr += '  <a href="' + followActor + '">\n'
    followStr += '  <img loading="lazy" src="' + followProfileUrl + '"/></a>\n'
    followStr += \
        '  <p class="followText">' + translate['Stop following'] + \
        ' ' + getNicknameFromActor(followActor) + \
        '@' + followDomain + ' ?</p>\n'
    followStr += '  <form method="POST" action="' + \
        originPathStr + '/unfollowconfirm">\n'
    followStr += '    <input type="hidden" name="actor" value="' + \
        followActor + '">\n'
    followStr += \
        '    <button type="submit" class="button" name="submitYes">' + \
        translate['Yes'] + '</button>\n'
    followStr += \
        '    <a href="' + originPathStr + '"><button class="button">' + \
        translate['No'] + '</button></a>\n'
    followStr += '  </form>\n'
    followStr += '</center>\n'
    followStr += '</div>\n'
    followStr += '</div>\n'
    followStr += htmlFooter()
    return followStr


def htmlPersonOptions(translate: {}, baseDir: str,
                      domain: str, originPathStr: str,
                      optionsActor: str,
                      optionsProfileUrl: str,
                      optionsLink: str,
                      pageNumber: int,
                      donateUrl: str,
                      xmppAddress: str,
                      matrixAddress: str,
                      ssbAddress: str,
                      blogAddress: str,
                      toxAddress: str,
                      PGPpubKey: str,
                      PGPfingerprint: str,
                      emailAddress) -> str:
    """Show options for a person: view/follow/block/report
    """
    optionsDomain, optionsPort = getDomainFromActor(optionsActor)
    optionsDomainFull = optionsDomain
    if optionsPort:
        if optionsPort != 80 and optionsPort != 443:
            optionsDomainFull = optionsDomain + ':' + str(optionsPort)

    if os.path.isfile(baseDir + '/accounts/options-background-custom.jpg'):
        if not os.path.isfile(baseDir + '/accounts/options-background.jpg'):
            copyfile(baseDir + '/accounts/options-background.jpg',
                     baseDir + '/accounts/options-background.jpg')

    followStr = 'Follow'
    blockStr = 'Block'
    nickname = None
    optionsNickname = None
    if originPathStr.startswith('/users/'):
        nickname = originPathStr.split('/users/')[1]
        if '/' in nickname:
            nickname = nickname.split('/')[0]
        if '?' in nickname:
            nickname = nickname.split('?')[0]
        followerDomain, followerPort = getDomainFromActor(optionsActor)
        if isFollowingActor(baseDir, nickname, domain, optionsActor):
            followStr = 'Unfollow'

        optionsNickname = getNicknameFromActor(optionsActor)
        optionsDomainFull = optionsDomain
        if optionsPort:
            if optionsPort != 80 and optionsPort != 443:
                optionsDomainFull = optionsDomain + ':' + str(optionsPort)
        if isBlocked(baseDir, nickname, domain,
                     optionsNickname, optionsDomainFull):
            blockStr = 'Block'

    optionsLinkStr = ''
    if optionsLink:
        optionsLinkStr = \
            '    <input type="hidden" name="postUrl" value="' + \
            optionsLink + '">'
    cssFilename = baseDir + '/epicyon-options.css'
    if os.path.isfile(baseDir + '/options.css'):
        cssFilename = baseDir + '/options.css'
    with open(cssFilename, 'r') as cssFile:
        profileStyle = cssFile.read()
        profileStyle = \
            profileStyle.replace('--follow-text-entry-width: 90%;',
                                 '--follow-text-entry-width: 20%;')

    # To snooze, or not to snooze? That is the question
    snoozeButtonStr = 'Snooze'
    if nickname:
        if isPersonSnoozed(baseDir, nickname, domain, optionsActor):
            snoozeButtonStr = 'Unsnooze'

    donateStr = ''
    if donateUrl:
        donateStr = \
            '    <a href="' + donateUrl + \
            '"><button class="button" name="submitDonate">' + \
            translate['Donate'] + '</button></a>\n'

    optionsStr = htmlHeader(cssFilename, profileStyle)
    optionsStr += '<br><br>\n'
    optionsStr += '<div class="options">\n'
    optionsStr += '  <div class="optionsAvatar">\n'
    optionsStr += '  <center>\n'
    optionsStr += '  <a href="' + optionsActor + '">\n'
    optionsStr += '  <img loading="lazy" src="' + optionsProfileUrl + \
        '"/></a>\n'
    handle = getNicknameFromActor(optionsActor) + '@' + optionsDomain
    optionsStr += \
        '  <p class="optionsText">' + translate['Options for'] + \
        ' @' + handle + '</p>\n'
    if emailAddress:
        optionsStr += \
            '<p class="imText">' + translate['Email'] + \
            ': <a href="mailto:' + \
            emailAddress + '">' + emailAddress + '</a></p>\n'
    if xmppAddress:
        optionsStr += \
            '<p class="imText">' + translate['XMPP'] + \
            ': <a href="xmpp:' + xmppAddress + '">' + \
            xmppAddress + '</a></p>\n'
    if matrixAddress:
        optionsStr += \
            '<p class="imText">' + translate['Matrix'] + ': ' + \
            matrixAddress + '</p>\n'
    if ssbAddress:
        optionsStr += \
            '<p class="imText">SSB: ' + ssbAddress + '</p>\n'
    if blogAddress:
        optionsStr += \
            '<p class="imText">Blog: <a href="' + blogAddress + '">' + \
            blogAddress + '</a></p>\n'
    if toxAddress:
        optionsStr += \
            '<p class="imText">Tox: ' + toxAddress + '</p>\n'
    if PGPfingerprint:
        optionsStr += '<p class="pgp">PGP: ' + \
            PGPfingerprint.replace('\n', '<br>') + '</p>\n'
    if PGPpubKey:
        optionsStr += '<p class="pgp">' + \
            PGPpubKey.replace('\n', '<br>') + '</p>\n'
    optionsStr += '  <form method="POST" action="' + \
        originPathStr + '/personoptions">\n'
    optionsStr += '    <input type="hidden" name="pageNumber" value="' + \
        str(pageNumber) + '">\n'
    optionsStr += '    <input type="hidden" name="actor" value="' + \
        optionsActor + '">\n'
    optionsStr += '    <input type="hidden" name="avatarUrl" value="' + \
        optionsProfileUrl + '">\n'
    if optionsNickname:
        handle = optionsNickname + '@' + optionsDomainFull
        petname = getPetName(baseDir, nickname, domain, handle)
        optionsStr += \
            translate['Petname'] + ': ' + \
            '<input type="text" name="optionpetname" value="' + \
            petname + '">\n' \
            '<button type="submit" class="buttonsmall" ' + \
            'name="submitPetname">' + \
            translate['Submit'] + '</button><br>\n'

    if isFollowingActor(baseDir, nickname, domain, optionsActor):
        if receivingCalendarEvents(baseDir, nickname, domain,
                                   optionsNickname, optionsDomainFull):
            optionsStr += \
                '<input type="checkbox" ' + \
                'class="profilecheckbox" name="onCalendar" checked> ' + \
                translate['Receive calendar events from this account'] + \
                '<button type="submit" class="buttonsmall" ' + \
                'name="submitOnCalendar">' + \
                translate['Submit'] + '</button><br>\n'
        else:
            optionsStr += \
                '<input type="checkbox" ' + \
                'class="profilecheckbox" name="onCalendar"> ' + \
                translate['Receive calendar events from this account'] + \
                '<button type="submit" class="buttonsmall" ' + \
                'name="submitOnCalendar">' + \
                translate['Submit'] + '</button><br>\n'

    optionsStr += optionsLinkStr
    optionsStr += \
        '    <a href="/"><button type="button" class="button" ' + \
        'name="submitBack">' + translate['Go Back'] + '</button></a>\n'
    optionsStr += \
        '    <button type="submit" class="button" name="submitView">' + \
        translate['View'] + '</button>\n'
    optionsStr += donateStr
    optionsStr += \
        '    <button type="submit" class="button" name="submit' + \
        followStr + '">' + translate[followStr] + '</button>\n'
    optionsStr += \
        '    <button type="submit" class="button" name="submit' + \
        blockStr + '">' + translate[blockStr] + '</button>\n'
    optionsStr += \
        '    <button type="submit" class="button" name="submitDM">' + \
        translate['DM'] + '</button>\n'
    optionsStr += \
        '    <button type="submit" class="button" name="submit' + \
        snoozeButtonStr + '">' + translate[snoozeButtonStr] + '</button>\n'
    optionsStr += \
        '    <button type="submit" class="button" name="submitReport">' + \
        translate['Report'] + '</button>\n'

    personNotes = ''
    personNotesFilename = \
        baseDir + '/accounts/' + nickname + '@' + domain + \
        '/notes/' + handle + '.txt'
    if os.path.isfile(personNotesFilename):
        with open(personNotesFilename, 'r') as fp:
            personNotes = fp.read()

    optionsStr += \
        '    <br><br>' + translate['Notes'] + ': \n'
    optionsStr += '<button type="submit" class="buttonsmall" ' + \
        'name="submitPersonNotes">' + \
        translate['Submit'] + '</button><br>\n'
    optionsStr += \
        '    <textarea id="message" ' + \
        'name="optionnotes" style="height:400px">' + \
        personNotes + '</textarea>\n'

    optionsStr += '  </form>\n'
    optionsStr += '</center>\n'
    optionsStr += '</div>\n'
    optionsStr += '</div>\n'
    optionsStr += htmlFooter()
    return optionsStr


def htmlUnblockConfirm(translate: {}, baseDir: str,
                       originPathStr: str,
                       blockActor: str,
                       blockProfileUrl: str) -> str:
    """Asks to confirm unblocking an actor
    """
    blockDomain, port = getDomainFromActor(blockActor)

    if os.path.isfile(baseDir + '/img/block-background.png'):
        if not os.path.isfile(baseDir + '/accounts/block-background.png'):
            copyfile(baseDir + '/img/block-background.png',
                     baseDir + '/accounts/block-background.png')

    cssFilename = baseDir + '/epicyon-follow.css'
    if os.path.isfile(baseDir + '/follow.css'):
        cssFilename = baseDir + '/follow.css'
    with open(cssFilename, 'r') as cssFile:
        profileStyle = cssFile.read()
    blockStr = htmlHeader(cssFilename, profileStyle)
    blockStr += '<div class="block">\n'
    blockStr += '  <div class="blockAvatar">\n'
    blockStr += '  <center>\n'
    blockStr += '  <a href="' + blockActor + '">\n'
    blockStr += '  <img loading="lazy" src="' + blockProfileUrl + '"/></a>\n'
    blockStr += \
        '  <p class="blockText">' + translate['Stop blocking'] + ' ' + \
        getNicknameFromActor(blockActor) + '@' + blockDomain + ' ?</p>\n'
    blockStr += '  <form method="POST" action="' + \
        originPathStr + '/unblockconfirm">\n'
    blockStr += '    <input type="hidden" name="actor" value="' + \
        blockActor + '">\n'
    blockStr += \
        '    <button type="submit" class="button" name="submitYes">' + \
        translate['Yes'] + '</button>\n'
    blockStr += \
        '    <a href="' + originPathStr + '"><button class="button">' + \
        translate['No'] + '</button></a>\n'
    blockStr += '  </form>\n'
    blockStr += '</center>\n'
    blockStr += '</div>\n'
    blockStr += '</div>\n'
    blockStr += htmlFooter()
    return blockStr


def htmlSearchEmojiTextEntry(translate: {},
                             baseDir: str, path: str) -> str:
    """Search for an emoji by name
    """
    # emoji.json is generated so that it can be customized and the changes
    # will be retained even if default_emoji.json is subsequently updated
    if not os.path.isfile(baseDir + '/emoji/emoji.json'):
        copyfile(baseDir + '/emoji/default_emoji.json',
                 baseDir + '/emoji/emoji.json')

    actor = path.replace('/search', '')
    domain, port = getDomainFromActor(actor)

    if os.path.isfile(baseDir + '/img/search-background.png'):
        if not os.path.isfile(baseDir + '/accounts/search-background.png'):
            copyfile(baseDir + '/img/search-background.png',
                     baseDir + '/accounts/search-background.png')

    cssFilename = baseDir + '/epicyon-follow.css'
    if os.path.isfile(baseDir + '/follow.css'):
        cssFilename = baseDir + '/follow.css'
    with open(cssFilename, 'r') as cssFile:
        profileStyle = cssFile.read()
    emojiStr = htmlHeader(cssFilename, profileStyle)
    emojiStr += '<div class="follow">\n'
    emojiStr += '  <div class="followAvatar">\n'
    emojiStr += '  <center>\n'
    emojiStr += \
        '  <p class="followText">' + \
        translate['Enter an emoji name to search for'] + '</p>\n'
    emojiStr += '  <form method="POST" action="' + \
        actor + '/searchhandleemoji">\n'
    emojiStr += '    <input type="hidden" name="actor" value="' + \
        actor + '">\n'
    emojiStr += '    <input type="text" name="searchtext" autofocus><br>\n'
    emojiStr += \
        '    <button type="submit" class="button" name="submitSearch">' + \
        translate['Submit'] + '</button>\n'
    emojiStr += '  </form>\n'
    emojiStr += '  </center>\n'
    emojiStr += '  </div>\n'
    emojiStr += '</div>\n'
    emojiStr += htmlFooter()
    return emojiStr


def weekDayOfMonthStart(monthNumber: int, year: int) -> int:
    """Gets the day number of the first day of the month
    1=sun, 7=sat
    """
    firstDayOfMonth = datetime(year, monthNumber, 1, 0, 0)
    return int(firstDayOfMonth.strftime("%w")) + 1


def htmlCalendarDay(translate: {},
                    baseDir: str, path: str,
                    year: int, monthNumber: int, dayNumber: int,
                    nickname: str, domain: str, dayEvents: [],
                    monthName: str, actor: str) -> str:
    """Show a day within the calendar
    """
    accountDir = baseDir + '/accounts/' + nickname + '@' + domain
    calendarFile = accountDir + '/.newCalendar'
    if os.path.isfile(calendarFile):
        os.remove(calendarFile)

    cssFilename = baseDir + '/epicyon-calendar.css'
    if os.path.isfile(baseDir + '/calendar.css'):
        cssFilename = baseDir + '/calendar.css'
    with open(cssFilename, 'r') as cssFile:
        calendarStyle = cssFile.read()

    calActor = actor
    if '/users/' in actor:
        calActor = '/users/' + actor.split('/users/')[1]

    calendarStr = htmlHeader(cssFilename, calendarStyle)
    calendarStr += '<main><table class="calendar">\n'
    calendarStr += '<caption class="calendar__banner--month">\n'
    calendarStr += \
        '  <a href="' + calActor + '/calendar?year=' + str(year) + \
        '?month=' + str(monthNumber) + '">\n'
    calendarStr += \
        '  <h1>' + str(dayNumber) + ' ' + monthName + \
        '</h1></a><br><span class="year">' + str(year) + '</span>\n'
    calendarStr += '</caption>\n'
    calendarStr += '<tbody>\n'

    iconsDir = getIconsDir(baseDir)

    if dayEvents:
        for eventPost in dayEvents:
            eventTime = None
            eventDescription = None
            eventPlace = None
            postId = None
            # get the time place and description
            for ev in eventPost:
                if ev['type'] == 'Event':
                    if ev.get('postId'):
                        postId = ev['postId']
                    if ev.get('startTime'):
                        eventDate = \
                            datetime.strptime(ev['startTime'],
                                              "%Y-%m-%dT%H:%M:%S%z")
                        eventTime = eventDate.strftime("%H:%M").strip()
                    if ev.get('name'):
                        eventDescription = ev['name'].strip()
                elif ev['type'] == 'Place':
                    if ev.get('name'):
                        eventPlace = ev['name']

            deleteButtonStr = ''
            if postId:
                deleteButtonStr = \
                    '<td class="calendar__day__icons"><a href="' + calActor + \
                    '/eventdelete?id=' + postId + '?year=' + str(year) + \
                    '?month=' + str(monthNumber) + '?day=' + str(dayNumber) + \
                    '?time=' + eventTime + \
                    '">\n<img class="calendardayicon" loading="lazy" alt="' + \
                    translate['Delete this event'] + ' |" title="' + \
                    translate['Delete this event'] + '" src="/' + \
                    iconsDir + '/delete.png" /></a></td>\n'

            if eventTime and eventDescription and eventPlace:
                calendarStr += \
                    '<tr><td class="calendar__day__time"><b>' + eventTime + \
                    '</b></td><td class="calendar__day__event">' + \
                    '<span class="place">' + \
                    eventPlace + '</span><br>' + eventDescription + \
                    '</td>' + deleteButtonStr + '</tr>\n'
            elif eventTime and eventDescription and not eventPlace:
                calendarStr += \
                    '<tr><td class="calendar__day__time"><b>' + eventTime + \
                    '</b></td><td class="calendar__day__event">' + \
                    eventDescription + '</td>' + deleteButtonStr + '</tr>\n'
            elif not eventTime and eventDescription and not eventPlace:
                calendarStr += \
                    '<tr><td class="calendar__day__time">' + \
                    '</td><td class="calendar__day__event">' + \
                    eventDescription + '</td>' + deleteButtonStr + '</tr>\n'
            elif not eventTime and eventDescription and eventPlace:
                calendarStr += \
                    '<tr><td class="calendar__day__time"></td>' + \
                    '<td class="calendar__day__event"><span class="place">' + \
                    eventPlace + '</span><br>' + eventDescription + \
                    '</td>' + deleteButtonStr + '</tr>\n'
            elif eventTime and not eventDescription and eventPlace:
                calendarStr += \
                    '<tr><td class="calendar__day__time"><b>' + eventTime + \
                    '</b></td><td class="calendar__day__event">' + \
                    '<span class="place">' + \
                    eventPlace + '</span></td>' + \
                    deleteButtonStr + '</tr>\n'

    calendarStr += '</tbody>\n'
    calendarStr += '</table></main>\n'
    calendarStr += htmlFooter()

    return calendarStr


def htmlCalendar(translate: {},
                 baseDir: str, path: str,
                 httpPrefix: str, domainFull: str) -> str:
    """Show the calendar for a person
    """
    iconsDir = getIconsDir(baseDir)
    domain = domainFull
    if ':' in domainFull:
        domain = domainFull.split(':')[0]

    monthNumber = 0
    dayNumber = None
    year = 1970
    actor = httpPrefix + '://' + domainFull + path.replace('/calendar', '')
    if '?' in actor:
        first = True
        for p in actor.split('?'):
            if not first:
                if '=' in p:
                    if p.split('=')[0] == 'year':
                        numStr = p.split('=')[1]
                        if numStr.isdigit():
                            year = int(numStr)
                    elif p.split('=')[0] == 'month':
                        numStr = p.split('=')[1]
                        if numStr.isdigit():
                            monthNumber = int(numStr)
                    elif p.split('=')[0] == 'day':
                        numStr = p.split('=')[1]
                        if numStr.isdigit():
                            dayNumber = int(numStr)
            first = False
        actor = actor.split('?')[0]

    currDate = datetime.now()
    if year == 1970 and monthNumber == 0:
        year = currDate.year
        monthNumber = currDate.month

    nickname = getNicknameFromActor(actor)

    if os.path.isfile(baseDir + '/img/calendar-background.png'):
        if not os.path.isfile(baseDir + '/accounts/calendar-background.png'):
            copyfile(baseDir + '/img/calendar-background.png',
                     baseDir + '/accounts/calendar-background.png')

    months = ('January', 'February', 'March', 'April',
              'May', 'June', 'July', 'August', 'September',
              'October', 'November', 'December')
    monthName = translate[months[monthNumber - 1]]

    if dayNumber:
        dayEvents = None
        events = \
            getTodaysEvents(baseDir, nickname, domain,
                            year, monthNumber, dayNumber)
        if events:
            if events.get(str(dayNumber)):
                dayEvents = events[str(dayNumber)]
        return htmlCalendarDay(translate, baseDir, path,
                               year, monthNumber, dayNumber,
                               nickname, domain, dayEvents,
                               monthName, actor)

    events = \
        getCalendarEvents(baseDir, nickname, domain, year, monthNumber)

    prevYear = year
    prevMonthNumber = monthNumber - 1
    if prevMonthNumber < 1:
        prevMonthNumber = 12
        prevYear = year - 1

    nextYear = year
    nextMonthNumber = monthNumber + 1
    if nextMonthNumber > 12:
        nextMonthNumber = 1
        nextYear = year + 1

    print('Calendar year=' + str(year) + ' month=' + str(monthNumber) +
          ' ' + str(weekDayOfMonthStart(monthNumber, year)))

    if monthNumber < 12:
        daysInMonth = \
            (date(year, monthNumber + 1, 1) - date(year, monthNumber, 1)).days
    else:
        daysInMonth = \
            (date(year + 1, 1, 1) - date(year, monthNumber, 1)).days
    # print('daysInMonth ' + str(monthNumber) + ': ' + str(daysInMonth))

    cssFilename = baseDir + '/epicyon-calendar.css'
    if os.path.isfile(baseDir + '/calendar.css'):
        cssFilename = baseDir + '/calendar.css'
    with open(cssFilename, 'r') as cssFile:
        calendarStyle = cssFile.read()

    calActor = actor
    if '/users/' in actor:
        calActor = '/users/' + actor.split('/users/')[1]

    calendarStr = htmlHeader(cssFilename, calendarStyle)
    calendarStr += '<main><table class="calendar">\n'
    calendarStr += '<caption class="calendar__banner--month">\n'
    calendarStr += \
        '  <a href="' + calActor + '/calendar?year=' + str(prevYear) + \
        '?month=' + str(prevMonthNumber) + '">'
    calendarStr += \
        '  <img loading="lazy" alt="' + translate['Previous month'] + \
        '" title="' + translate['Previous month'] + '" src="/' + iconsDir + \
        '/prev.png" class="buttonprev"/></a>\n'
    calendarStr += '  <a href="' + calActor + '/inbox">'
    calendarStr += '  <h1>' + monthName + '</h1></a>\n'
    calendarStr += \
        '  <a href="' + calActor + '/calendar?year=' + str(nextYear) + \
        '?month=' + str(nextMonthNumber) + '">'
    calendarStr += \
        '  <img loading="lazy" alt="' + translate['Next month'] + \
        '" title="' + translate['Next month'] + '" src="/' + iconsDir + \
        '/prev.png" class="buttonnext"/></a>\n'
    calendarStr += '</caption>\n'
    calendarStr += '<thead>\n'
    calendarStr += '<tr>\n'
    calendarStr += '  <th class="calendar__day__header">' + \
        translate['Sun'] + '</th>\n'
    calendarStr += '  <th class="calendar__day__header">' + \
        translate['Mon'] + '</th>\n'
    calendarStr += '  <th class="calendar__day__header">' + \
        translate['Tue'] + '</th>\n'
    calendarStr += '  <th class="calendar__day__header">' + \
        translate['Wed'] + '</th>\n'
    calendarStr += '  <th class="calendar__day__header">' + \
        translate['Thu'] + '</th>\n'
    calendarStr += '  <th class="calendar__day__header">' + \
        translate['Fri'] + '</th>\n'
    calendarStr += '  <th class="calendar__day__header">' + \
        translate['Sat'] + '</th>\n'
    calendarStr += '</tr>\n'
    calendarStr += '</thead>\n'
    calendarStr += '<tbody>\n'

    dayOfMonth = 0
    dow = weekDayOfMonthStart(monthNumber, year)
    for weekOfMonth in range(1, 7):
        if dayOfMonth == daysInMonth:
            continue
        calendarStr += '  <tr>\n'
        for dayNumber in range(1, 8):
            if (weekOfMonth > 1 and dayOfMonth < daysInMonth) or \
               (weekOfMonth == 1 and dayNumber >= dow):
                dayOfMonth += 1

                isToday = False
                if year == currDate.year:
                    if currDate.month == monthNumber:
                        if dayOfMonth == currDate.day:
                            isToday = True
                if events.get(str(dayOfMonth)):
                    url = calActor + '/calendar?year=' + \
                        str(year) + '?month=' + \
                        str(monthNumber) + '?day=' + str(dayOfMonth)
                    dayLink = '<a href="' + url + '">' + \
                        str(dayOfMonth) + '</a>'
                    # there are events for this day
                    if not isToday:
                        calendarStr += \
                            '    <td class="calendar__day__cell" ' + \
                            'data-event="">' + \
                            dayLink + '</td>\n'
                    else:
                        calendarStr += \
                            '    <td class="calendar__day__cell" ' + \
                            'data-today-event="">' + \
                            dayLink + '</td>\n'
                else:
                    # No events today
                    if not isToday:
                        calendarStr += \
                            '    <td class="calendar__day__cell">' + \
                            str(dayOfMonth) + '</td>\n'
                    else:
                        calendarStr += \
                            '    <td class="calendar__day__cell" ' + \
                            'data-today="">' + str(dayOfMonth) + '</td>\n'
            else:
                calendarStr += '    <td class="calendar__day__cell"></td>\n'
        calendarStr += '  </tr>\n'

    calendarStr += '</tbody>\n'
    calendarStr += '</table></main>\n'
    calendarStr += htmlFooter()
    return calendarStr


def removeOldHashtags(baseDir: str, maxMonths: int) -> str:
    """Remove old hashtags
    """
    if maxMonths > 11:
        maxMonths = 11
    maxDaysSinceEpoch = \
        (datetime.utcnow() - datetime(1970, 1 + maxMonths, 1)).days
    removeHashtags = []

    for subdir, dirs, files in os.walk(baseDir + '/tags'):
        for f in files:
            tagsFilename = os.path.join(baseDir + '/tags', f)
            if not os.path.isfile(tagsFilename):
                continue
            # get last modified datetime
            modTimesinceEpoc = os.path.getmtime(tagsFilename)
            lastModifiedDate = datetime.fromtimestamp(modTimesinceEpoc)
            fileDaysSinceEpoch = (lastModifiedDate - datetime(1970, 1, 1)).days

            # check of the file is too old
            if fileDaysSinceEpoch < maxDaysSinceEpoch:
                removeHashtags.append(tagsFilename)

    for removeFilename in removeHashtags:
        try:
            os.remove(removeFilename)
        except BaseException:
            pass


def htmlHashTagSwarm(baseDir: str, actor: str) -> str:
    """Returns a tag swarm of today's hashtags
    """
    currTime = datetime.utcnow()
    daysSinceEpoch = (currTime - datetime(1970, 1, 1)).days
    daysSinceEpochStr = str(daysSinceEpoch) + ' '
    tagSwarm = []

    for subdir, dirs, files in os.walk(baseDir + '/tags'):
        for f in files:
            tagsFilename = os.path.join(baseDir + '/tags', f)
            if not os.path.isfile(tagsFilename):
                continue
            # get last modified datetime
            modTimesinceEpoc = os.path.getmtime(tagsFilename)
            lastModifiedDate = datetime.fromtimestamp(modTimesinceEpoc)
            fileDaysSinceEpoch = (lastModifiedDate - datetime(1970, 1, 1)).days
            # check if the file was last modified today
            if fileDaysSinceEpoch != daysSinceEpoch:
                continue

            hashTagName = f.split('.')[0]
            if isBlockedHashtag(baseDir, hashTagName):
                continue
            if daysSinceEpochStr not in open(tagsFilename).read():
                continue
            with open(tagsFilename, 'r') as tagsFile:
                line = tagsFile.readline()
                lineCtr = 1
                tagCtr = 0
                maxLineCtr = 1
                while line:
                    if '  ' not in line:
                        line = tagsFile.readline()
                        lineCtr += 1
                        # don't read too many lines
                        if lineCtr >= maxLineCtr:
                            break
                        continue
                    postDaysSinceEpochStr = line.split('  ')[0]
                    if not postDaysSinceEpochStr.isdigit():
                        line = tagsFile.readline()
                        lineCtr += 1
                        # don't read too many lines
                        if lineCtr >= maxLineCtr:
                            break
                        continue
                    postDaysSinceEpoch = int(postDaysSinceEpochStr)
                    if postDaysSinceEpoch < daysSinceEpoch:
                        break
                    if postDaysSinceEpoch == daysSinceEpoch:
                        if tagCtr == 0:
                            tagSwarm.append(hashTagName)
                        tagCtr += 1

                    line = tagsFile.readline()
                    lineCtr += 1
                    # don't read too many lines
                    if lineCtr >= maxLineCtr:
                        break

    if not tagSwarm:
        return ''
    tagSwarm.sort()
    tagSwarmStr = ''
    ctr = 0
    for tagName in tagSwarm:
        tagSwarmStr += \
            '<a href="' + actor + '/tags/' + tagName + \
            '" class="hashtagswarm">' + tagName + '</a>\n'
        ctr += 1
    tagSwarmHtml = tagSwarmStr.strip() + '\n'
    return tagSwarmHtml


def htmlSearch(translate: {},
               baseDir: str, path: str, domain: str) -> str:
    """Search called from the timeline icon
    """
    actor = path.replace('/search', '')
    searchNickname = getNicknameFromActor(actor)

    if os.path.isfile(baseDir + '/img/search-background.png'):
        if not os.path.isfile(baseDir + '/accounts/search-background.png'):
            copyfile(baseDir + '/img/search-background.png',
                     baseDir + '/accounts/search-background.png')

    cssFilename = baseDir + '/epicyon-search.css'
    if os.path.isfile(baseDir + '/follow.css'):
        cssFilename = baseDir + '/follow.css'
    with open(cssFilename, 'r') as cssFile:
        profileStyle = cssFile.read()
    followStr = htmlHeader(cssFilename, profileStyle)

    # show a banner above the search box
    searchBannerFilename = \
        baseDir + '/accounts/' + searchNickname + '@' + domain + \
        '/search_banner.png'
    if not os.path.isfile(searchBannerFilename):
        theme = getConfigParam(baseDir, 'theme').lower()
        if theme == 'default':
            theme = ''
        else:
            theme = '_' + theme
        themeSearchBannerFilename = \
            baseDir + '/img/search_banner' + theme + '.png'
        if os.path.isfile(themeSearchBannerFilename):
            copyfile(themeSearchBannerFilename, searchBannerFilename)
    if os.path.isfile(searchBannerFilename):
        followStr += '<center>\n<div class="searchBanner">\n' + \
            '<br><br><br><br><br><br><br><br>' + \
            '<br><br><br><br><br><br><br><br>\n</div>\n</center>\n'

    # show the search box
    followStr += '<div class="follow">\n'
    followStr += '  <div class="followAvatar">\n'
    followStr += '  <center>\n'
    idx = 'Enter an address, shared item, !history, #hashtag, ' + \
        '*skill or :emoji: to search for'
    followStr += \
        '  <p class="followText">' + translate[idx] + '</p>\n'
    followStr += '  <form method="POST" ' + \
        'accept-charset="UTF-8" action="' + actor + '/searchhandle">\n'
    followStr += \
        '    <input type="hidden" name="actor" value="' + actor + '">\n'
    followStr += '    <input type="text" name="searchtext" autofocus><br>\n'
    followStr += '    <a href="/"><button type="button" class="button" ' + \
        'name="submitBack">' + translate['Go Back'] + '</button></a>\n'
    followStr += '    <button type="submit" class="button" ' + \
        'name="submitSearch">' + translate['Submit'] + '</button>\n'
    followStr += '  </form>\n'
    followStr += '  <p class="hashtagswarm">' + \
        htmlHashTagSwarm(baseDir, actor) + '</p>\n'
    followStr += '  </center>\n'
    followStr += '  </div>\n'
    followStr += '</div>\n'
    followStr += htmlFooter()
    return followStr


def htmlProfileAfterSearch(recentPostsCache: {}, maxRecentPosts: int,
                           translate: {},
                           baseDir: str, path: str, httpPrefix: str,
                           nickname: str, domain: str, port: int,
                           profileHandle: str,
                           session, cachedWebfingers: {}, personCache: {},
                           debug: bool, projectVersion: str,
                           YTReplacementDomain: str) -> str:
    """Show a profile page after a search for a fediverse address
    """
    if '/users/' in profileHandle or \
       '/accounts/' in profileHandle or \
       '/channel/' in profileHandle or \
       '/profile/' in profileHandle or \
       '/@' in profileHandle:
        searchNickname = getNicknameFromActor(profileHandle)
        searchDomain, searchPort = getDomainFromActor(profileHandle)
    else:
        if '@' not in profileHandle:
            print('DEBUG: no @ in ' + profileHandle)
            return None
        if profileHandle.startswith('@'):
            profileHandle = profileHandle[1:]
        if '@' not in profileHandle:
            print('DEBUG: no @ in ' + profileHandle)
            return None
        searchNickname = profileHandle.split('@')[0]
        searchDomain = profileHandle.split('@')[1]
        searchPort = None
        if ':' in searchDomain:
            searchPortStr = searchDomain.split(':')[1]
            if searchPortStr.isdigit():
                searchPort = int(searchPortStr)
            searchDomain = searchDomain.split(':')[0]
    if searchPort:
        print('DEBUG: Search for handle ' +
              str(searchNickname) + '@' + str(searchDomain) + ':' +
              str(searchPort))
    else:
        print('DEBUG: Search for handle ' +
              str(searchNickname) + '@' + str(searchDomain))
    if not searchNickname:
        print('DEBUG: No nickname found in ' + profileHandle)
        return None
    if not searchDomain:
        print('DEBUG: No domain found in ' + profileHandle)
        return None

    searchDomainFull = searchDomain
    if searchPort:
        if searchPort != 80 and searchPort != 443:
            if ':' not in searchDomain:
                searchDomainFull = searchDomain + ':' + str(searchPort)

    profileStr = ''
    cssFilename = baseDir + '/epicyon-profile.css'
    if os.path.isfile(baseDir + '/epicyon.css'):
        cssFilename = baseDir + '/epicyon.css'
    with open(cssFilename, 'r') as cssFile:
        wf = \
            webfingerHandle(session,
                            searchNickname + '@' + searchDomainFull,
                            httpPrefix, cachedWebfingers,
                            domain, projectVersion)
        if not wf:
            print('DEBUG: Unable to webfinger ' +
                  searchNickname + '@' + searchDomainFull)
            print('DEBUG: cachedWebfingers ' + str(cachedWebfingers))
            print('DEBUG: httpPrefix ' + httpPrefix)
            print('DEBUG: domain ' + domain)
            return None
        if not isinstance(wf, dict):
            print('WARN: Webfinger search for ' +
                  searchNickname + '@' + searchDomainFull +
                  ' did not return a dict. ' +
                  str(wf))
            return None

        personUrl = None
        if wf.get('errors'):
            personUrl = httpPrefix + '://' + \
                searchDomainFull + '/users/' + searchNickname

        profileStr = 'https://www.w3.org/ns/activitystreams'
        asHeader = {
            'Accept': 'application/activity+json; profile="' + profileStr + '"'
        }
        if not personUrl:
            personUrl = getUserUrl(wf)
        if not personUrl:
            # try single user instance
            asHeader = {
                'Accept': 'application/ld+json; profile="' + profileStr + '"'
            }
            personUrl = httpPrefix + '://' + searchDomainFull
        profileJson = \
            getJson(session, personUrl, asHeader, None,
                    projectVersion, httpPrefix, domain)
        if not profileJson:
            asHeader = {
                'Accept': 'application/ld+json; profile="' + profileStr + '"'
            }
            profileJson = \
                getJson(session, personUrl, asHeader, None,
                        projectVersion, httpPrefix, domain)
        if not profileJson:
            print('DEBUG: No actor returned from ' + personUrl)
            return None
        avatarUrl = ''
        if profileJson.get('icon'):
            if profileJson['icon'].get('url'):
                avatarUrl = profileJson['icon']['url']
        if not avatarUrl:
            avatarUrl = getPersonAvatarUrl(baseDir, personUrl,
                                           personCache, True)
        displayName = searchNickname
        if profileJson.get('name'):
            displayName = profileJson['name']
        profileDescription = ''
        if profileJson.get('summary'):
            profileDescription = profileJson['summary']
        outboxUrl = None
        if not profileJson.get('outbox'):
            if debug:
                pprint(profileJson)
                print('DEBUG: No outbox found')
            return None
        outboxUrl = profileJson['outbox']
        profileBackgroundImage = ''
        if profileJson.get('image'):
            if profileJson['image'].get('url'):
                profileBackgroundImage = profileJson['image']['url']

        profileStyle = cssFile.read().replace('image.png',
                                              profileBackgroundImage)
        if httpPrefix != 'https':
            profileStyle = profileStyle.replace('https://',
                                                httpPrefix + '://')
        # url to return to
        backUrl = path
        if not backUrl.endswith('/inbox'):
            backUrl += '/inbox'

        profileDescriptionShort = profileDescription
        if '\n' in profileDescription:
            if len(profileDescription.split('\n')) > 2:
                profileDescriptionShort = ''
        else:
            if '<br>' in profileDescription:
                if len(profileDescription.split('<br>')) > 2:
                    profileDescriptionShort = ''
        # keep the profile description short
        if len(profileDescriptionShort) > 256:
            profileDescriptionShort = ''
        # remove formatting from profile description used on title
        avatarDescription = ''
        if profileJson.get('summary'):
            if isinstance(profileJson['summary'], str):
                avatarDescription = \
                    profileJson['summary'].replace('<br>', '\n')
                avatarDescription = avatarDescription.replace('<p>', '')
                avatarDescription = avatarDescription.replace('</p>', '')
                if '<' in avatarDescription:
                    avatarDescription = removeHtml(avatarDescription)
        profileStr = ' <div class="hero-image">\n'
        profileStr += '  <div class="hero-text">\n'
        if avatarUrl:
            profileStr += \
                '    <img loading="lazy" src="' + avatarUrl + \
                '" alt="' + avatarDescription + '" title="' + \
                avatarDescription + '" class="title">\n'
        profileStr += '    <h1>' + displayName + '</h1>\n'
        profileStr += '    <p><b>@' + searchNickname + '@' + \
            searchDomainFull + '</b></p>\n'
        profileStr += '    <p>' + profileDescriptionShort + '</p>\n'
        profileStr += '  </div>\n'
        profileStr += '</div>\n'
        profileStr += '<div class="container">\n'
        profileStr += '  <form method="POST" action="' + \
            backUrl + '/followconfirm">\n'
        profileStr += '    <center>\n'
        profileStr += \
            '      <input type="hidden" name="actor" value="' + \
            personUrl + '">\n'
        profileStr += \
            '      <a href="' + backUrl + '"><button class="button">' + \
            translate['Go Back'] + '</button></a>\n'
        profileStr += \
            '      <button type="submit" class="button" name="submitYes">' + \
            translate['Follow'] + '</button>\n'
        profileStr += \
            '      <button type="submit" class="button" name="submitView">' + \
            translate['View'] + '</button>\n'
        profileStr += '    </center>\n'
        profileStr += '  </form>\n'
        profileStr += '</div>\n'

        iconsDir = getIconsDir(baseDir)
        i = 0
        for item in parseUserFeed(session, outboxUrl, asHeader,
                                  projectVersion, httpPrefix, domain):
            if not item.get('type'):
                continue
            if item['type'] != 'Create' and item['type'] != 'Announce':
                continue
            if not item.get('object'):
                continue
            profileStr += \
                individualPostAsHtml(True, recentPostsCache, maxRecentPosts,
                                     iconsDir, translate, None, baseDir,
                                     session, cachedWebfingers, personCache,
                                     nickname, domain, port,
                                     item, avatarUrl, False, False,
                                     httpPrefix, projectVersion, 'inbox',
                                     YTReplacementDomain,
                                     False, False, False, False, False)
            i += 1
            if i >= 20:
                break

    return htmlHeader(cssFilename, profileStyle) + profileStr + htmlFooter()