// "USE Strict";
/* global $, myLoggedInAjax */
/* global g_dbTables, g_isAssociation, g_postUnderEdit, g_listAccounts, g_listNewsFeedPosts, g_isInitialized, g_isLoginInitialized */
/* global EDITIONS, EDITION, EDITION_ENUM, BADGES_TABLES, DOW, SCOPE_NATIONAL, SCOPE_COUNCIL, VISIBILITY */
/* global getNow, getVocab, getVocabTroop, getVocabScope, getEditionMoniker */
/* global getAccountID, getAccountCouncilID, getCouncilName, getGroupName, getOtherEditions, amISupport, isAssociation, isTestServer */
/* global getMyRole, getMyScope, getMyInsignia, getLoginEmail, isEmbedded */
/* global getOuting, getOutingName, getEventMoniker, findLocalEvent, buildOutingOverview, getEventURL, getEditionInfo, inferOutingKeyEdition, isCollabOutingID, isCancelled */
/* global appendNoteAttachments */
/* global getClickVerb */
/* global hasPermPost */
/* global showNewsTab */
/* global findByProperty */
/* global size, joinAnd, clone, makeTrueSet */
/* global formatDate, addSeeMoreLink, linkifyHtmlDescription, getTextForegroundColor, getEndOfDayTimestamp */
/* global console_log, console_error, console_trace */
/* global parseCouncilAudience, parseEditionAudience, sanitizeHtml */
/* global buildTitle */
var g_mapHiddenNewsFeedPosts = {};
var g_setNewsFeedPostReportedViews = {};
var g_jsonNewsFeedPostViews = {};
var g_jsonNewsFeedPostViewSnapshots = {};
var g_mapAvatarStyles = {};
var g_mapBannerHeights = {};
var g_mapBannerMaxDimensions = {};
var g_timerRebuildHelloPosts = null;

/* exported getPostByOutingKey */
function getPostByOutingKey( outingKey )
{
    const listPosts = g_listNewsFeedPosts.filter( (post) => post.outingkey == outingKey );
    if ( listPosts.length > 0 )
        return listPosts[0];

    return null;
}

// create a seriously-stripped down version of the event that can be used with buildOutingOverview
/* exported clonePostOuting */
function clonePostOuting( outing )
{
    const setPropertiesToKeep = makeTrueSet( [ "displayname", "location", "cost", "date", "isallday", "timestampend", "labels" ], false );

    const outingClone = clone( outing );
    for ( const propID in outingClone )
        if ( ! setPropertiesToKeep.has( propID ) )
	    delete outingClone[propID];

    return outingClone;
}

function buildPost( selector, post, note, mapHiddenPosts, nMarginTop, bShowAll, isPreview )
{
    const hasPerm = hasPermPost();
    let editionEnum = EDITION_ENUM;
    const isHello = location.href.match( /\/hello\// );

    const outing = getOuting( post.outingkey ) || post.outingoverview;	// if the the outing is in our account, use it, otherwise default to the cached overview

    let htmlAuthor = "";
    if ( post.author && post.author.name )
    {
	let htmlHide = "";
	if ( ! isPreview && hasPerm )
	{
	    if ( post.accountid == getAccountID() )		// is this one of my account's posts?
	    {
		const jsPrenavigation = selector == "#news-feed" ? "gotoNewsFeedArchive();" : "";
		htmlHide += `<a href="javascript:void(0)" onclick="${jsPrenavigation}doEditPost(${post.postid});">Edit</a>`;
	    }
	}

	if ( ! post.pinned )
	{
	    if ( ! bShowAll )
	    {
		if ( htmlHide )
		    htmlHide += " | ";
		htmlHide += `<a href="javascript:void(0)" onclick="doHidePost(${post.postid},${post.isautogen ? post.timestampupdated : note.timestamp});">Hide</a>`;
	    }
	    else if ( mapHiddenPosts[post.postid] !== undefined && mapHiddenPosts[post.postid] >= note.timestamp && (post.timestampend == -1 || post.timestampend > getNow()) )
	    {
		if ( htmlHide )
		    htmlHide += " | ";
		htmlHide += `<a href="javascript:void(0)" onclick="doUnhidePost(${post.postid},${note.timestamp});">Unhide</a>`;
	    }
	}

	htmlHide = ! isPreview && htmlHide ? `<small class="creole-tips" style="margin-right:0;font-size:90%;">${htmlHide}</small>` : "";

	let strAuthorName = post.author.name;
	let strAuthorAvatar = post.author.avatar;
	let useRoundedAvatar = true;
	if ( isHello )
	{
	    const jsonAccount = findByProperty( g_listAccounts, "accountid", post.accountid );
	    if ( jsonAccount )
		editionEnum = findByProperty( EDITIONS, "edition", jsonAccount.edition ).id;
	    else		// it's possible that this could be a national/council post, but since you can only see either a) your own Troop posts, or b) Group/Council/National, it's a safe bet that the Committee enum should be used
	    {
		const edition = findByProperty( EDITIONS, "isassociation", true );
		if ( edition )
		    editionEnum = findByProperty( EDITIONS, "isassociation", true ).id;
		else
		    console_trace( "could not lookup account " + post.accountid + ", no association enum?" );
	    }
	}

	if ( post.isgroup )			// rewrite the name to say that this is the "1st Muddy Paw" if it's a post from the GC
	{
	    let htmlGroupName = getGroupName( undefined, null );
	    if ( htmlGroupName == null )
	        htmlGroupName = `Your ${getVocab( "Group" )}`;
	    else if ( ! htmlGroupName.match( /\s+Group/ ) )		// unless the group already has the word "Group" in it ("1st Muddy Paw Scouting Group" or "1st Muddy Paw Group Committee")
		htmlGroupName += ` ${getVocab( "Group" )}`;

	    if ( getMyInsignia() )					// is there an official Group avatar?  use it
	    {
	        strAuthorAvatar = getMyInsignia();			// always refresh, with the latest version of the Group's insignia
		useRoundedAvatar = false;
	    }

	    strAuthorName = `<span style="color:black;font-weight:var(--font-weight-bold);"><span style="font-weight:var(--font-weight-heavy);">${htmlGroupName}</span> (by </span>${strAuthorName}<span style="color:black;">)</span>`;
	}

	else if ( ! g_isAssociation && post.accountid == getAccountID() || isHello )		// Troop news?
	{
	    let strNewsLabel = "News";
	    if ( ! isHello )
		strNewsLabel = getVocabTroopNews();
	    else if ( editionEnum > -1 )
		strNewsLabel = findByProperty( EDITIONS, "id", editionEnum ).moniker + " News";

	    const strLabel = outing ? `Upcoming ${isHello || post.accountid != getAccountID() ? "Event" : getEventMoniker( outing, false )}` : strNewsLabel;
	    strAuthorName = `<span style="color:black;font-weight:var(--font-weight-bold);"><span style="font-weight:var(--font-weight-heavy);">${strLabel}</span> (by </span>${strAuthorName}<span style="color:black;">)</span>`;
	}

	else										// Area/Council/National post?
	{
	    strAuthorName = `<span style="color:black;font-weight:var(--font-weight-bold);"><span style="font-weight:var(--font-weight-heavy);">${strAuthorName}</span></span>`;
	    useRoundedAvatar = false;
	}

	let htmlAvatar = "";
	if ( strAuthorAvatar )
	{
	    const MAX_AVATAR = 40;
	    let strStyle = `height:${MAX_AVATAR}px;width:${MAX_AVATAR}px;margin-top:5px;`;

	    // have we previously tested that the avatar exists?
	    if ( g_mapAvatarStyles[strAuthorAvatar] === undefined )		// no, we've never tried loading this image
	    {
		//console_log( `validating avatar = ${strAuthorAvatar}` );
		g_mapAvatarStyles[strAuthorAvatar] = strStyle;		// prevent the test from happening multiple times

		const img = new Image();
		img.onload = function() {
		    if ( this.width > this.height && this.width > MAX_AVATAR )
		    {
			const nAvatarHeight = this.height * ( MAX_AVATAR / this.width );
		        strStyle = `max-width:${MAX_AVATAR}px;margin-top:${4 + (MAX_AVATAR - nAvatarHeight) / 2}px;`;
			g_mapAvatarStyles[strAuthorAvatar] = strStyle;
		    }
		}
		img.onerror = function() {
		    console_log( `couldn't load "${strAuthorAvatar}"` );
		    g_mapAvatarStyles[strAuthorAvatar] = "";			// record that it couldn't be loaded, so just use the default styling
		}
		img.src = strAuthorAvatar;
	    }

	    if ( g_mapAvatarStyles[strAuthorAvatar] )
	        strStyle = g_mapAvatarStyles[strAuthorAvatar];

	    htmlAvatar = `<img alt="" src=${strAuthorAvatar} style="float:left;${strStyle};object-fit:cover;margin-right:10px;border-radius:${useRoundedAvatar ? 50 : 10}%;"/>`;
	}

	let htmlSpecialAudience = "";
	if ( post.specialaudience )
	    htmlSpecialAudience = post.specialaudience;

	if ( htmlSpecialAudience )		// wrap it up
	    htmlSpecialAudience = `<div style="font-size:80%;float:right;margin-top:-1.25em;"><span>Visibility: </span> ${htmlSpecialAudience}</div>`;

	let htmlTimestamp = formatDate( note.timestamp );

	if ( note.timestamp > getNow() )
	    htmlTimestamp = `<img style="vertical-align:-1px;margin-right:5px;" alt="" src="images/planned.gif" ${buildTitle( "This post is scheduled" )}>` + htmlTimestamp;
	else if ( post.timestamp > 0 && post.timestampend < getNow() )
	    htmlTimestamp += ` <span style="color:var(--color-warning-medium);font-weight:var(--font-weight-heavy);">(Expired on ${formatDate( post.timestampend )})</span>`;

	const nAuthorHeight = strAuthorAvatar ? "height:42px;padding-bottom:5px;" : "height:auto;padding-bottom:5px;";
	htmlAuthor = `<table style="width:100%;background-color:white;border-top-left-radius:5px;border-top-right-radius:5px;" cellspacing=0><tr><td style="padding:0 0 0 15px;width:1px;border-top-left-radius:5px;vertical-align:top;border-bottom:none;">${htmlAvatar}</td><td style="${nAuthorHeight}margin: -1px -15px 0;padding-left:0;padding-right:10px;border-top-right-radius:5px;vertical-align:top;border-bottom:none;">${htmlHide}<h1 style="color:var(--color-heading);font-weight:var(--font-weight-bold);font-size:90%;margin-bottom:2px;margin-top:5px;">${strAuthorName}</h1><div style="font-size:80%;">${htmlTimestamp}</div>${htmlSpecialAudience}</td></tr></table>`;
    }

    const strMarginTop = ! post.author.name ? "margin-top:.75em;" : "";

    let height = post.height;

    let strNote = "";
    let htmlBanner = "";

    // should we prepend a banner image?
    if ( post.banner !== undefined && post.banner.src )
    {
	// have we previously tested that the badge exists?
	if ( g_mapBannerHeights[post.banner.src] === undefined )	// no, we've never tried loading this image
	{
	    //console_log( `validating banner.img.src = ${post.banner.src}` );
	    g_mapBannerHeights[post.banner.src] = "pending";		// prevent the test from happening multiple times

	    const img = new Image();
	    img.onload = function() {
		const MAX_BANNER = { width: 600, height: 120 };
		const fScaleWidth = this.width > MAX_BANNER.width ? MAX_BANNER.width / this.width : 1;				// traditional banner... squeeze into 600px wide
		const fScaleHeight = fScaleWidth == 1 && this.height > MAX_BANNER.height ? MAX_BANNER.height / this.height : 1;	// if we're not constrained by width, then assume we just have a photo/crest and constrain by height

		let strMaxDimension = "";
		if ( fScaleWidth == 1 && fScaleHeight == 1 )
		    g_mapBannerHeights[post.banner.src] = post.height == -1 ? `${this.height}px` : height;		// either auto-size, or just keep using the user-specified height
		else if ( fScaleWidth == 1 )
		{
		    const nMaxHeight = post.banner.position != 0 && outing ? 80 : MAX_BANNER.height;
		    strMaxDimension = `max-height: ${nMaxHeight}px`;
		    g_mapBannerHeights[post.banner.src] = post.height == -1 ? `${Math.min( nMaxHeight, fScaleHeight * this.height)}px` : height;		// either auto-size, or just keep using the user-specified height
		}
		else
		{
		    strMaxDimension = `max-width: ${MAX_BANNER.width}px`;
		    g_mapBannerHeights[post.banner.src] = post.height == -1 ? `${fScaleWidth * this.height}px` : height;		// either auto-size, or just keep using the user-specified height
		}

		g_mapBannerMaxDimensions[post.banner.src] = strMaxDimension;

		// for a new post, can we dynamically pick "above the text" or "left" as a banner position, based on its ratio?
		if ( post.isnew && ! post.banner.position )
		{
		    const ePosition = (1.0 * this.width) / this.height > 16.0 / 9.0 ? 0 : 1;
		    $( "#post-banner-position-selector" ).val( ePosition );		// greater than 16:9 aspect ration

		    doSelectBannerPosition( false );					// update the selector to reflect this auto-selected position
		}
		else
		    $( "#post-banner-position-selector" ).val( post.banner.position );	// just reuse our current selection

		if ( isPreview )
		    updatePostPreview( true );

		if ( isHello )
		{
		    if ( g_timerRebuildHelloPosts )					// if there are multiple banner images, then keep resetting the timer until the last one is loaded
			window.clearTimeout( g_timerRebuildHelloPosts );

		    g_timerRebuildHelloPosts = window.setTimeout( function() {
		        g_timerRebuildHelloPosts = null;
			showNewsTab();
		    }, 1000 );
		}
	    }
	    img.onerror = function() {
		g_mapBannerHeights[post.banner.src] = "";				// record that it couldn't be loaded, so we have no height
		if ( isPreview )
		    updatePostPreview( true );
	    }
	    img.src = post.banner.src;
	}

	if ( g_mapBannerHeights[post.banner.src] !== undefined && ! g_mapBannerHeights[post.banner.src] )
	    ;	// don't bother adding the banner, because we know it doesn't exist
	else
	{
	    const strTooltip = post.banner.tooltip ? `title="${post.banner.tooltip}"` : "";
	    const jsOnClick_banner = post.banner.url ? `onclick="window.open('${post.banner.url}','_blank');"` : "";
	    const strAltText = post.banner.url ? `Go to ${post.banner.url}` : "";

	    const stylePosition = ! post.banner.position || post.banner.position == 0 ? "" : post.banner.position == 1 ? "float:left;margin-top:.25em;margin-right:1em;" : "float:right;margin-top:.25em;margin-left:.5em;";
	    htmlBanner = `<img alt="${strAltText}" class="banner" ${strTooltip} ${jsOnClick_banner} src="${post.banner.src}" style="${stylePosition}${g_mapBannerMaxDimensions[post.banner.src]}"/>`;

	    if ( height == -1 && g_mapBannerHeights[post.banner.src] !== undefined && g_mapBannerHeights[post.banner.src] != "pending" )	// do we have a previously-calculated or user-specified height?
		height = g_mapBannerHeights[post.banner.src];
	}
    }

    // for event posts, initialize with the name of the event and a quick who/when/where
    if ( outing )
    {
	let htmlDetails = `<h1 style="margin-top:0;margin-bottom:0;">${getOutingName( outing )}</h1>`;
	htmlDetails += `<ul style="margin-top:0;margin-bottom:1em;" class="outing-overview">${buildOutingOverview( outing, false )}</ul>`;
	if ( post.banner.position == 1 )
	    strNote = `<table><tr><td style="border:none;vertical-align:top;">${htmlBanner}</td><td style="border:none;vertical-align:top;">${htmlDetails}</td></tr></table>`;
	else
	    strNote = htmlBanner + htmlDetails;
    }
    else
	strNote = htmlBanner;

    strNote += linkifyHtmlDescription( sanitizeHtml( note.note.replace( /^(<p>(&nbsp;)*<\/p>)+/, "" ), true ), editionEnum, -1, true );

    // KLUDGE: Reinsert the class="button" that got stripped out of buttons
    strNote = strNote.replace( /<button/g, "<button class=\"button\"" );

    // should we append an action button?
    let strButton = "";
    if ( post.button !== undefined && post.button.url )
    {
	let jsOnClick_button = post.button.url;

	if ( post.button.url.match( /^event:(.+)/ ) )		// is this a link to an event?
	{
	    let outingKey = RegExp.$1;
	    if ( typeof findLocalEvent != "undefined" )
		outingKey = findLocalEvent( outingKey );	// this link could be to a subscription source event that we have already subscribed to

	    if ( ! isCollabOutingID( outingKey ) )
		jsOnClick_button = `doViewEvent( '${findLocalEvent( outingKey )}' )`;
	    else
		jsOnClick_button = `window.open( '${getEventURL( outingKey, getEditionInfo( inferOutingKeyEdition( outingKey ) ).directory )}' )`;
	}

	else if ( ! post.button.url.match( /^javascript:/ ) )	// unless this explicitly some javascript, we should treat as a URL and wrap in a window.open
	    jsOnClick_button = `window.open('${post.button.url}','_blank');`;

	const strLabel = post.button.label ? post.button.label : post.button.url;
	const strTooltip = post.button.tooltip ? `title="${post.button.tooltip}"` : "";
	strButton = `<button class="button" ${strTooltip} onclick="${jsOnClick_button}" style="background-color:var(--color-clickable);margin-left:15px;margin-top:5px;margin-bottom:10px;">${strLabel}</button>`;
    }

    // the image existence check is asynchronous, so we have to assume that we could get here before the first
    // attempted check is finished, and therefore, we have to turn an auto-size height into a reasonable default
    if ( height == -1 )
	height = "75px";

    if ( typeof height != "string" )			// just a number like "3"?
	height = `${height}px`;				// ... append units

    const jsOnClick = ! bShowAll && post.height != 0 ? `onclick="seeMore( undefined, '${selector} div.white-page[data-postid=${post.postid}] > div.clipped');"` : "";
    let bgColor_post = post.bgcolor !== undefined ? `background-color:${post.bgcolor};` : "";

    let strClass = "";
    if ( outing && isCancelled( outing ) )
    {
	strClass = "cancelled";
	bgColor_post = "";
    }

    const bgColor_note = note.bgcolor !== undefined ? `background-color:${note.bgcolor};` : "";		// unused?

    // If there is a float anywhere else in the post's rendering, it can screw up the calculation of height for the div.clipped container.
    // So we add a <div style="clear:both;"></div> after our note, and that seems to resolve the issue, as per https://stackoverflow.com/a/16568504
    strNote += `<div style="clear:both;"></div>`;

    $( selector ).append( `<div data-postid=${post.postid} class="view html white-page light-background ${strClass}" style="margin-top:${nMarginTop};padding:0 0 5px;${bgColor_post}" ${jsOnClick}>${htmlAuthor}<div class="external mce-content-body clipped" style="line-height:1.25em;padding:5px 15px;adding-bottom:5px;${bgColor_note}${strMarginTop}">${strNote}</div>${strButton}</div>` );

    nMarginTop = "0";

    appendNoteAttachments( selector + ` div.white-page[data-postid=${post.postid}]`, post, false ); // append after the .clipped and .see-more
    const div = $( selector + ` div.white-page[data-postid=${post.postid}] div.attachments` );
    div.find( "h1.listheader" ).remove();
    div.css( "margin", "0 0 -5px" );
    div.css( "padding", "5px 15px" );
    div.css( "border-top", "solid 1px #ccc" );
    div.css( "background-color", "#f4f4f8" );

    // do we need to add a "see more" link?   If a post.height is specified (i.e., not zero), then it means let the div autosize to show all content
    if ( ! bShowAll && post.height != 0 )
    {
	const divClipped = $( selector + ` div.white-page[data-postid=${post.postid}] > div.clipped` );

	let cssColor = post.seemorecolor ? post.seemorecolor : getTextForegroundColor( post.bgcolor );
	if ( cssColor )
	    cssColor = `style="color:${cssColor};"`;

	addSeeMoreLink( $( selector + ` div.white-page[data-postid=${post.postid}]` ), selector + ` div.white-page[data-postid=${post.postid}] > div.clipped`, 5, cssColor, divClipped, height, "margin-left:15px;" );
    }
}

function newsFeedNoteComparable( noteA, noteB )
{
    // scheduled posts could have been updated many days ago, and are finally only being displayed.
    // On the other hand, a post could have been edited.
    //
    // So we take the largest of the scheduled timestamp and the updated timestamp
    const nMaxA = Math.max( noteA.timestamp, noteA.timestampupdated );
    const nMaxB = Math.max( noteB.timestamp, noteB.timestampupdated );

    return nMaxB - nMaxA;
}

/**
 * selector	the div whose body will be populated with content, e.g., "#news-feed" or "#news-feed-archive div.content"
 * bShowAll	do we show hidden posts, too?
 * listPosts	the list of filtered posts
 */
/* exported updateNewsFeed */
function updateNewsFeed( selector, bShowAll, listPosts )
{
    $( selector + " div.white-page[data-postid]" ).remove()		// scrub all old posts
    $( selector ).hide();						// hide the entire container

    if ( ! g_isInitialized || ! g_isLoginInitialized )
        return;								// not ready, because we may not have all the information to decide if the Account Settings posts are valid

    $( selector ).show();						// show the container, although we may re-hide it below, if it's a youth/parent login and there's nothing to show

    const hasPerm = hasPermPost();

    const now = getNow();
    const myLoginRole = getMyRole();
    const isAssoc = g_isAssociation;
    const strMyScope = getMyScope();

    let listNotes = [];
    const listPinnedNotes = [];

    let nPosts = 0;

    for ( const post of listPosts )
    {
	let timeExpiry = post.timestampend;
	let outingKey = post.outingkey;

	// if it's an event post, always defer to the event's end date, because the event could have been rescheduled since the post was created
	if ( outingKey )
	{
	    // look up if a local event exists, in case 'show other schedules' isn't showing the source event
	    if ( typeof findLocalEvent != "undefined" )
		outingKey = findLocalEvent( outingKey );		// this link could be to a subscription source event that we have already subscribed to

	    const outing = getOuting( outingKey );
	    if ( outing )						// if we can find the event, then we can use an accurate (perhaps rescheduled) end timestamp
		timeExpiry = getEndOfDayTimestamp( outing.timestampend );
	    else if ( post.accountid == getAccountID() )		// if we can't find the event, and it's a post from our account, then we should skip this post
	        continue;
	}

        if ( timeExpiry > 0 && timeExpiry < now )			// is this an expired post?  most of the time we want to skip them
	{
	    // we only show an expired post if a) it's the archive, b) it's a non event-based posts, and c) we are actually able to edit it

	    if ( ! bShowAll )						// home page?
	        continue;
	    if ( post.outingkey )					// event-based?
	        continue;
	    if ( post.accountid != getAccountID() || ! hasPerm )	// can't edit them anyway?
	        continue;
	}

	// make sure that this post is actually targeted for us
	const mapSpecialAudience = {};
	if ( post.accountid != getAccountID() )				// don't skip my own posts
	{
	    if ( post.targeteditions !== undefined && post.targeteditions && post.targeteditions.match( `<${EDITION}>` ) == null )
		continue;

	    if ( post.targetcouncils !== undefined && post.targetcouncils && post.targetcouncils.match( `<${getAccountCouncilID()}>` ) == null )
		continue;

	    if ( post.visibility == VISIBILITY.leaders.id && myLoginRole != "v" )
		continue;
	}

	else if ( hasPerm )							// for my own posts, decide if we should report a special audience
	{
	    if ( isAssoc )
	    {
		let listCouncilIDs = null;
		if ( strMyScope == SCOPE_NATIONAL )
		{
		    if ( post.targetcouncils )						// I'm a national account, but my post is available to one or more councils?
		    {
			listCouncilIDs = parseCouncilAudience( post.targetcouncils );
			if ( listCouncilIDs.length == size( g_dbTables['Councils'] ) )	// the "specific councils" actually includes all of them
			    listCouncilIDs = null;					// ... this is normal for national accounts
		    }

		    if ( listCouncilIDs )
			mapSpecialAudience.councils = "in " + joinAnd( listCouncilIDs.map( function ( councilID ) { return `<b>${getCouncilName( councilID )}</b>`; } ) ) + " " + getVocab( listCouncilIDs.length == 1 ? "Council" : "Councils" );
		    else
			mapSpecialAudience.councils = `in all ${getVocab( "Councils" )}`;
		}
		else if ( strMyScope == SCOPE_COUNCIL )
		{
		    if ( post.targetcouncils )						// this post only visible to one or more specific councils?
		    {
			listCouncilIDs = parseCouncilAudience( post.targetcouncils );
			if ( listCouncilIDs.length == size( g_dbTables['Councils'] ) )	// the "specific councils" actually includes all of them
			    listCouncilIDs = null;
		    }

		    if ( listCouncilIDs )
			mapSpecialAudience.councils = "in " + joinAnd( listCouncilIDs.map( function ( councilID ) { return `<b>${getCouncilName( councilID )}</b>`; } ) ) + " " + getVocab( listCouncilIDs.length == 1 ? "Council" : "Councils" );
		    else
			mapSpecialAudience.councils = `in all ${getVocab( "Councils" )}`;
		}
		else
		    mapSpecialAudience.councils = `in our ${getVocab( strMyScope )}`; 									// Group accounts can't post outside of their domain

		let listEditions = null;
		if ( post.targeteditions )							// only visible to specific editions?
		{
		    listEditions = parseEditionAudience( post.targeteditions );
		    if ( getOtherEditions( true ).length == listEditions.length )		// the "specific editions" actually includes all of them
			listEditions = null;									// don't bother reporting this
		}

		if ( listEditions )					// a specific set of editions?
		{
		    let strCommitteeMembers = null;
		    const listScouterEditions = [];

		    for ( const strEdition of listEditions )
		    {
			if ( isAssociation( strEdition ) )
			    strCommitteeMembers = getVocab( "Leaders" );
			else
			    listScouterEditions.push( `<b>${getEditionMoniker( strEdition )}</b>` );
		    }

		    if ( post.visibility == VISIBILITY.everyone.id )
			mapSpecialAudience.editions = `everyone in ${joinAnd(listScouterEditions)}`;
		    else
		    {
			mapSpecialAudience.editions = `just ${joinAnd(listScouterEditions)} ${getVocab( "LEADERS_GENERIC" )}`;

			if ( strCommitteeMembers )
			    mapSpecialAudience.editions += ` and ${strCommitteeMembers}`;
		    }
		}

		else						// all editions?
		{
		    if ( post.visibility == VISIBILITY.everyone.id )
			mapSpecialAudience.editions = `everyone`;
		    else
			mapSpecialAudience.editions = `just ${getVocab( "LEADERS_GENERIC" )} and ${getVocab( "Leaders" )}`;
		}

		post.specialaudience = mapSpecialAudience.editions + " " + mapSpecialAudience.councils;
	    }

	    else
	    {
		if ( post.visibility == VISIBILITY.everyone.id )
		    mapSpecialAudience.editions = `everyone in this ${getVocabTroop()}`;
		else if ( ! isAssoc && post.accountid == getAccountID() )		// Troop news?
		    mapSpecialAudience.editions = `just ${getVocab( "Leaders" )} in this ${getVocabTroop()}`;
		else
		    mapSpecialAudience.editions = `just ${getVocab( "LEADERS_GENERIC" )} and ${getVocab( "Leaders" )} in this ${getVocabTroop()}`;

		post.specialaudience = mapSpecialAudience.editions;
	    }
	}

	// so now we have just those posts that a) aren't expired and b) match the target edition/audience
	for ( const note of post.content )			// this assumes the posts are sorted by timestamp (which the server does provide)
	{
	    // if we are displaying the entire newsfeed, then we have to show scheduled posts,
	    // but if it's just the home page newsfeed, then we skip posts that aren't due yet
	    if ( ! bShowAll && (note.timestamp > now /* || post.accountid == getAccountID() */ ) )
	        continue;

	    note.postid = post.postid;				// copy this into the note, so we can look up author, etc
	    note.timestampupdated = post.timestampupdated;	// copy this so sorting is faster

	    if ( post.pinned )
		listPinnedNotes.push( note );
	    else if ( bShowAll || g_mapHiddenNewsFeedPosts[post.postid] === undefined || g_mapHiddenNewsFeedPosts[post.postid] < note.timestamp )	// allow for "Hide".  Note that by comparing timestamp, posts are effectively unhidden when the content changes
		listNotes.push( note );

	    nPosts++;

	    break;			// don't bother looking through older content
	}
    }

    // sort all the content from all the posts we're going to display
    listNotes.sort( newsFeedNoteComparable );
    listPinnedNotes.sort( newsFeedNoteComparable );

    listNotes = listPinnedNotes.concat( listNotes );		// tack the regular posts on to the end of the pinned posts

    const mapNewsFeedPostViews = {};
    $( selector ).toggle( hasPerm || listNotes.length > 0 );

    if ( listNotes.length == 0 )
    {
	let strMessage = "";
	if ( selector != "#news-feed" )			// must be the archive, which shows all time-valid posts (including hidden)
	    strMessage = "There are no posts to show in your News Feed.";
	else if ( nPosts > 0 )				// must be some hidden posts, so it's worth directing the user to the "News Feed" archive
	    strMessage = `You're all caught up! To see previously-read posts, go to "News Feed."`;
	else if ( hasPerm )			// truly nothing to see... if you can't post then don't show anything, otherwise give a hint about creating a new post
	    strMessage = "There's nothing in your News Feed.";

	if ( hasPerm )
	    strMessage += `<span class="hint">${getClickVerb()} "Add" to create a post for others in your ${isAssoc ? getVocabScope() : getVocabTroop()} to see.</span>`;

	if ( strMessage )
	    $( selector ).append( `<div class="white-page" data-postid=-1 style="margin-top:0;">${strMessage}</div>` );
    }

    // now generate the actual HTML for all our posts
    for ( const note of listNotes )
    {
	const post = findByProperty( listPosts, "postid", note.postid );

	if ( ( post.recordviews === undefined || post.recordviews ) && g_setNewsFeedPostReportedViews[post.postid] === undefined )		// have we reported seeing this post?
	{
	     g_setNewsFeedPostReportedViews[post.postid] = 1;				// record that we've seen it
	     mapNewsFeedPostViews[post.postid] = post.accountid;			// record the author account of the post
	}

	buildPost( selector, post, note, g_mapHiddenNewsFeedPosts, "0", bShowAll, false );

	if ( g_jsonNewsFeedPostViews[post.postid] !== undefined && hasPerm )
	{
	    const view = g_jsonNewsFeedPostViews[post.postid];
	    if ( view.participant.total === undefined )
		view.participant.total = 0;
	    if ( view.participant.unique === undefined )
		view.participant.unique = 0;

            let htmlRawTable = "";

	    const htmlMoreLink = g_jsonNewsFeedPostViewSnapshots[post.postid] !== undefined ? `<span class="link-color" style="margin-left:.5em;">(<a href="javascript:void(0)" onclick="let evt = arguments[0] || window.event; $('div[data-postid=${post.postid}] table.snapshots').show(); evt.stopPropagation();$(this).parent().hide();">daily snapshots</a>)</span>` : "";
	    if ( htmlMoreLink )		// do we need to bother building a table of daily snapshots
	    {
		const htmlTableHeader = post.visibility != VISIBILITY.everyone.id
		    ? `<tr class="row1"><th/><th/><th colspan=4>${getVocab( "LEADERS_GENERIC" )}</th></tr><tr class="row2"><th>Date</th><th>Day</th><th>Reach</th><th class="delta">Delta</th><th>Views</th><th class="delta">Delta</th></tr>`
		    : `<tr class="row1"><th/><th/><th colspan=4>${getVocab( "LEADERS_GENERIC" )}</th><th colspan=4>Non-${getVocab( "LEADERS_GENERIC" )}</th></tr><tr><th>Date</th><th>Day</th><th>Reach</th><th class="delta">Delta</th><th>Views</th><th class="delta">Delta</th><th>Reach</th><th class="delta">Delta</th><th>Views</th><th class="delta">Delta</th></tr>`;

		const listHtmlTableRows = [];
		let lastDaily = { leader: { unique: 0, total: 0 }, participant: { unique: 0, total: 0 } };
		for ( const snapshot of g_jsonNewsFeedPostViewSnapshots[post.postid] )
		{
		    const strDate = formatDate( snapshot.timestamp );
		    const strDOW = DOW[new Date( snapshot.timestamp ).getDay()];
		    const daily = JSON.parse( snapshot.snapshot );

		    const dailyParticipant = $.extend( { unique: 0, total: 0 }, daily.participant );	// this ensures missing values are treated as zero
		    const dailyLeader = $.extend( { unique: 0, total: 0 }, daily.leader );	// this ensures missing values are treated as zero

		    listHtmlTableRows.push( post.visibility != VISIBILITY.everyone.id
			? `<tr><td>${strDate}</td><td>${strDOW}</td><td>${dailyLeader.unique + dailyParticipant.unique}</td><td class='delta'>+${dailyLeader.unique + dailyParticipant.unique - lastDaily.leader.unique - lastDaily.participant.unique}</td><td>${dailyLeader.total + dailyParticipant.total}</td><td class='delta'>+${dailyLeader.total + dailyParticipant.total - lastDaily.leader.total - lastDaily.participant.total}</td></tr>`
			: `<tr><td>${strDate}</td><td>${strDOW}</td><td>${dailyLeader.unique}</td><td class='delta'>+${dailyLeader.unique - lastDaily.leader.unique}</td><td>${dailyLeader.total}</td><td class='delta'>+${dailyLeader.total - lastDaily.leader.total}</td><td>${dailyParticipant.unique}</td><td class='delta'>+${dailyParticipant.unique - lastDaily.participant.unique}</td><td>${dailyParticipant.total}</td><td class='delta'>+${dailyParticipant.total - lastDaily.participant.total}</td></tr>` );

		    lastDaily = { leader: dailyLeader, participant: dailyParticipant };
		}

		listHtmlTableRows.reverse();		// flip it, so the most recent date is at the top

		htmlRawTable = `<table class="snapshots" style="display:none;margin-top:.5em;margin-bottom:.25em;">${htmlTableHeader}${listHtmlTableRows.join("")}</table>`;
	    }

	    const htmlInsights = post.visibility != VISIBILITY.everyone.id
		? `Insights (${getVocab( "LEADERS_GENERIC" )}):</span> <b>${view.leader.unique + view.participant.unique}</b> people reached, <b>${view.leader.total + view.participant.total}</b> total views${htmlMoreLink}${htmlRawTable}</div>`

		: `Insights (${getVocab( "LEADERS_GENERIC" )} + Non-${getVocab( "LEADERS_GENERIC" )} = All):</span> <b>${view.leader.unique} + ${view.participant.unique} = ${view.leader.unique + view.participant.unique}</b> people reached, <b>${view.leader.total} + ${view.participant.total} = ${view.leader.total + view.participant.total}</b> total views${htmlMoreLink}${htmlRawTable}</div>`;

	    $( selector + ` div.white-page[data-postid=${post.postid}]` ).append( `<div style="border-top:solid 1px #ccc;margin:5px 0 -7px;background-color:#f8f8f0;padding:5px 12px;font-size:90%;border-bottom-left-radius:5px;border-bottom-right-radius:5px;"><span style="font-weight:var(--font-weight-bold);color:var(--color-link);">${htmlInsights}</div>` );
	}
    }

    // if any of the posts requested view-tracking, upload that information now
    if ( size( mapNewsFeedPostViews ) > 0 ) 	// if I've got anything to report
        ajaxUpdatePostViews( mapNewsFeedPostViews );
}

function ajaxUpdatePostViews( mapViews )
{
    if ( isEmbedded() || amISupport() || getLoginEmail() == "demo@example.com" || isTestServer() )
    	return;			// don't pollute the insights with my testing

    myLoggedInAjax( {
	url: BADGES_TABLES,
	data: `worksheet=PostViews&update=${encodeURIComponent(JSON.stringify( mapViews ))}`,
	error: function( request, textStatus, errorThrown )
	{
	    console_error( `Ignoring error updating PostViews: ${textStatus} (${errorThrown})`, true );
	},
	success: function()
	{
	    //console_log( "postviews updated" );
	}
    });
}

function getVocabTroopNews( isLowerCase = false )
{
    // If it's just an ordinary section (e.g., Pack) or the national account then just report
    // "Pack News" or "National News".  Otherwise, for Group/Area/Council accounts concatenate
    // the name and the scope, e.g., "1st Muddy Paw Group News" or "Cascadia Council News"
    let strWho = "News";
    if ( ! g_isAssociation || getMyScope() == SCOPE_NATIONAL )
        strWho = getVocabTroop();
    else
    {
	strWho = getGroupName();
	if ( ! strWho )								// I think this can happen when switching between accounts
	    strWho = "News";
	else if ( ! strWho.match( new RegExp( getVocabScope() ) ) )		// if our group name is "Shining Waters Council" then there's no need to add "Council"
	    strWho = strWho + ` ${getVocabScope()}`;
    }

    return `${strWho} ${isLowerCase ? "news" : "News"}`;
}

function updatePostPreview( isChange )
{
    if ( isChange )
        g_editorChanged = true;			// eslint-disable-line no-undef

    g_postUnderEdit.banner.src = $( "#post-banner-src" ).val();
    g_postUnderEdit.banner.url = $( "#post-banner-url" ).val();
    g_postUnderEdit.banner.tooltip = $( "#post-banner-tooltip" ).val();

    g_postUnderEdit.button.label = $( "#post-button-label" ).val();
    g_postUnderEdit.button.url = $( "#post-button-url" ).val();
    g_postUnderEdit.button.tooltip = $( "#post-button-tooltip" ).val();

    $( "#post-preview" ).empty();
    buildPost( "#post-preview", g_postUnderEdit, g_postUnderEdit.content[0], {}, "0", g_postUnderEdit.isexpanded, true );
}

function doSelectBannerPosition( isChange )
{
    g_postUnderEdit.isexpanded = false;			// reset this each time we change the selector
    g_postUnderEdit.banner.position = parseInt( $( "#post-banner-position-selector" ).val() );

    if ( isChange )
	delete g_mapBannerHeights[g_postUnderEdit.banner.src];

    updatePostPreview( true );
}
