From 924232a0d41c73d003016c95d13497c3c42e300f Mon Sep 17 00:00:00 2001 From: Bob Mottram Date: Tue, 9 Nov 2021 12:20:57 +0000 Subject: [PATCH 01/40] Use handle for artist --- media.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/media.py b/media.py index 184c8d2af..5b742a5e1 100644 --- a/media.py +++ b/media.py @@ -140,7 +140,7 @@ def _spoofMetaData(baseDir: str, nickname: str, domain: str, camMake, camModel, camSerialNumber) = \ spoofGeolocation(baseDir, spoofCity, currTimeAdjusted, decoySeed, None, None) - if os.system('exiftool -artist="' + nickname + '" ' + + if os.system('exiftool -artist=@"' + nickname + '@' + domain + '" ' + '-Make="' + camMake + '" ' + '-Model="' + camModel + '" ' + '-Comment="' + str(camSerialNumber) + '" ' + From 7d9a31ab91b104277cd0cf73bee299dee50d9ee2 Mon Sep 17 00:00:00 2001 From: Bob Mottram Date: Tue, 9 Nov 2021 17:39:58 +0000 Subject: [PATCH 02/40] Private pinned post json endpoint --- daemon.py | 76 ++++++++++++++++++++++++++++++++++++++++++++++--------- posts.py | 16 ++++++++++++ 2 files changed, 80 insertions(+), 12 deletions(-) diff --git a/daemon.py b/daemon.py index 05795bdcf..ce2d0166c 100644 --- a/daemon.py +++ b/daemon.py @@ -73,6 +73,7 @@ from person import removeAccount from person import canRemovePost from person import personSnooze from person import personUnsnooze +from posts import hasPrivatePinnedPost from posts import getOriginalPostFromAnnounceUrl from posts import savePostToBox from posts import getInstanceActorKey @@ -100,6 +101,7 @@ from inbox import runInboxQueue from inbox import runInboxQueueWatchdog from inbox import savePostToInboxQueue from inbox import populateReplies +from follow import isFollowerOfPerson from follow import followerApprovalActive from follow import isFollowingActor from follow import getFollowingFeed @@ -654,12 +656,9 @@ class PubServer(BaseHTTPRequestHandler): return False return True - def _secureMode(self) -> bool: - """http authentication of GET requests for json + def _secureModeActor(self) -> str: + """Returns the actor from the signed GET keyId """ - if not self.server.secureMode: - return True - signature = None if self.headers.get('signature'): signature = self.headers['signature'] @@ -669,9 +668,9 @@ class PubServer(BaseHTTPRequestHandler): # check that the headers are signed if not signature: if self.server.debug: - print('AUTH: secure mode, ' + + print('AUTH: secure mode actor, ' + 'GET has no signature in headers') - return False + return None # get the keyId, which is typically the instance actor keyId = None @@ -680,17 +679,25 @@ class PubServer(BaseHTTPRequestHandler): if signatureItem.startswith('keyId='): if '"' in signatureItem: keyId = signatureItem.split('"')[1] - break + # remove #main-key + if '#' in keyId: + keyId = keyId.split('#')[0] + return keyId + return None + + def _secureMode(self, force: bool = False) -> bool: + """http authentication of GET requests for json + """ + if not self.server.secureMode and not force: + return True + + keyId = self._secureModeActor() if not keyId: if self.server.debug: print('AUTH: secure mode, ' + 'failed to obtain keyId from signature') return False - # remove #main-key - if '#' in keyId: - keyId = keyId.split('#')[0] - # is the keyId (actor) valid? if not urlPermitted(keyId, self.server.federationList): if self.server.debug: @@ -12980,6 +12987,51 @@ class PubServer(BaseHTTPRequestHandler): nickname = self.path.split('/users/')[1] if '/' in nickname: nickname = nickname.split('/')[0] + showPinned = True + # is the pinned post for followers only? + if hasPrivatePinnedPost(self.server.baseDir, + self.server.httpPrefix, + nickname, self.server.domain, + self.server.domainFull, + self.server.systemLanguage): + if not self._secureMode(True): + # GET request signature failed + showPinned = False + else: + # the GET signature passes, but is this someone + # that follows us? + followerActor = self._secureModeActor() + followerNickname = getNicknameFromActor(followerActor) + followerDomain, followerPort = \ + getDomainFromActor(followerActor) + followerDomainFull = \ + getFullDomain(followerDomain, followerPort) + if not isFollowerOfPerson(self.server.baseDir, + nickname, self.server.domain, + followerNickname, + followerDomainFull): + showPinned = False + if not showPinned: + # follower check failed, so just return an empty collection + postContext = getIndividualPostContext() + actor = \ + self.server.httpPrefix + '://' + \ + self.server.domainFull + '/users/' + nickname + emptyCollectionJson = { + '@context': postContext, + 'id': actor + '/collections/featured', + 'orderedItems': [], + 'totalItems': 0, + 'type': 'OrderedCollection' + } + msg = json.dumps(emptyCollectionJson, + ensure_ascii=False).encode('utf-8') + msglen = len(msg) + self._set_headers('application/json', + msglen, None, callingDomain, False) + self._write(msg) + return + # return the featured posts collection self._getFeaturedCollection(callingDomain, self.server.baseDir, self.path, diff --git a/posts.py b/posts.py index 38be89bd0..3d519221b 100644 --- a/posts.py +++ b/posts.py @@ -1648,6 +1648,22 @@ def jsonPinPost(baseDir: str, httpPrefix: str, } +def hasPrivatePinnedPost(baseDir: str, httpPrefix: str, + nickname: str, domain: str, + domainFull: str, systemLanguage: str) -> bool: + """Whether the given account has a private pinned post + """ + pinnedPostJson = \ + getPinnedPostAsJson(baseDir, httpPrefix, + nickname, domain, + domainFull, systemLanguage) + if not pinnedPostJson: + return False + if not isPublicPost(pinnedPostJson): + return True + return False + + def regenerateIndexForBox(baseDir: str, nickname: str, domain: str, boxName: str) -> None: """Generates an index for the given box if it doesn't exist From c5b18b436c9bc29ea47ec1568fc8ea8519517583 Mon Sep 17 00:00:00 2001 From: Bob Mottram Date: Tue, 9 Nov 2021 18:03:17 +0000 Subject: [PATCH 03/40] Followers only posts can be pinned --- daemon.py | 23 ++++++++++++++++++++++- posts.py | 23 +++++++++++++++++++++-- webapp_profile.py | 6 ++++-- 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/daemon.py b/daemon.py index ce2d0166c..0dc818288 100644 --- a/daemon.py +++ b/daemon.py @@ -15547,8 +15547,10 @@ class PubServer(BaseHTTPRequestHandler): contentStr = \ getBaseContentFromPost(messageJson, self.server.systemLanguage) + followersOnly = False pinPost(self.server.baseDir, - nickname, self.server.domain, contentStr) + nickname, self.server.domain, contentStr, + followersOnly) return 1 if self._postToOutbox(messageJson, self.server.projectVersion, @@ -15791,6 +15793,16 @@ class PubServer(BaseHTTPRequestHandler): else: return -1 elif postType == 'newfollowers': + if not fields.get('pinToProfile'): + pinToProfile = False + else: + pinToProfile = True + # is the post message empty? + if not fields['message']: + # remove the pinned content from profile screen + undoPinnedPost(self.server.baseDir, + nickname, self.server.domain) + return 1 city = getSpoofedCity(self.server.city, self.server.baseDir, nickname, @@ -15830,6 +15842,15 @@ class PubServer(BaseHTTPRequestHandler): if messageJson: if fields['schedulePost']: return 1 + if pinToProfile: + contentStr = \ + getBaseContentFromPost(messageJson, + self.server.systemLanguage) + followersOnly = True + pinPost(self.server.baseDir, + nickname, self.server.domain, contentStr, + followersOnly) + return 1 if self._postToOutbox(messageJson, self.server.projectVersion, nickname): diff --git a/posts.py b/posts.py index 3d519221b..e3acde056 100644 --- a/posts.py +++ b/posts.py @@ -1562,7 +1562,7 @@ def _postIsAddressedToFollowers(baseDir: str, def pinPost(baseDir: str, nickname: str, domain: str, - pinnedContent: str) -> None: + pinnedContent: str, followersOnly: bool) -> None: """Pins the given post Id to the profile of then given account """ accountDir = acctDir(baseDir, nickname, domain) @@ -1570,6 +1570,18 @@ def pinPost(baseDir: str, nickname: str, domain: str, with open(pinnedFilename, 'w+') as pinFile: pinFile.write(pinnedContent) + privatePinnedFilename = accountDir + '/pinToProfile.private' + if followersOnly: + with open(privatePinnedFilename, 'w+') as pinFile: + pinFile.write('\n') + else: + if os.path.isfile(privatePinnedFilename): + try: + os.remove(privatePinnedFilename) + except BaseException: + print('EX: pinPost unable to delete private ' + + privatePinnedFilename) + def undoPinnedPost(baseDir: str, nickname: str, domain: str) -> None: """Removes pinned content for then given account @@ -1581,7 +1593,14 @@ def undoPinnedPost(baseDir: str, nickname: str, domain: str) -> None: os.remove(pinnedFilename) except BaseException: print('EX: undoPinnedPost unable to delete ' + pinnedFilename) - pass + + privatePinnedFilename = accountDir + '/pinToProfile.private' + if os.path.isfile(privatePinnedFilename): + try: + os.remove(privatePinnedFilename) + except BaseException: + print('EX: undoPinnedPost unable to delete private ' + + privatePinnedFilename) def getPinnedPostAsJson(baseDir: str, httpPrefix: str, diff --git a/webapp_profile.py b/webapp_profile.py index 269851597..e11a0a096 100644 --- a/webapp_profile.py +++ b/webapp_profile.py @@ -811,10 +811,12 @@ def htmlProfile(signingPrivateKeyPem: str, # get pinned post content accountDir = acctDir(baseDir, nickname, domain) pinnedFilename = accountDir + '/pinToProfile.txt' + privatePinnedFilename = accountDir + '/pinToProfile.private' pinnedContent = None if os.path.isfile(pinnedFilename): - with open(pinnedFilename, 'r') as pinFile: - pinnedContent = pinFile.read() + if not os.path.isfile(privatePinnedFilename): + with open(pinnedFilename, 'r') as pinFile: + pinnedContent = pinFile.read() profileHeaderStr = \ _getProfileHeader(baseDir, httpPrefix, From 98d09c48a88f790199faf4b5579283c41de54996 Mon Sep 17 00:00:00 2001 From: Bob Mottram Date: Tue, 9 Nov 2021 18:08:19 +0000 Subject: [PATCH 04/40] Show pinning checkbox on new followers only post screen --- webapp_create_post.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/webapp_create_post.py b/webapp_create_post.py index 34205d83d..0176e1005 100644 --- a/webapp_create_post.py +++ b/webapp_create_post.py @@ -575,7 +575,8 @@ def htmlNewPost(cssCache: {}, mediaInstance: bool, translate: {}, '\n' - if endpoint == 'newpost': + if endpoint == 'newpost' or \ + endpoint == 'newfollowers': dateAndLocation += \ '

\n' + for emojiContent, count in reactions.items(): + htmlStr += '
\n' + if count < 100: + countStr = str(count) + else: + countStr = '99+' + emojiContentStr = emojiContent + countStr + if interactive: + # urlencode the emoji + emojiContentEncoded = emojiContent + emojiContentStr = \ + ' ' + \ + emojiContentStr + '\n' + htmlStr += emojiContentStr + htmlStr += '
\n' + htmlStr += '
\n' + return htmlStr diff --git a/tests.py b/tests.py index bee2690c4..0855fa929 100644 --- a/tests.py +++ b/tests.py @@ -4652,8 +4652,7 @@ def _testFunctions(): 'E2EEremoveDevice', 'setOrganizationScheme', 'fill_headers', - '_nothing', - 'noOfReactions' + '_nothing' ] excludeImports = [ 'link', diff --git a/webapp_post.py b/webapp_post.py index e2c821748..fb32d3d56 100644 --- a/webapp_post.py +++ b/webapp_post.py @@ -79,6 +79,7 @@ from speaker import updateSpeaker from languages import autoTranslatePost from blocking import isBlocked from blocking import addCWfromLists +from reaction import htmlEmojiReactions def _htmlPostMetadataOpenGraph(domain: str, postJsonObject: {}) -> str: @@ -1879,13 +1880,18 @@ def individualPostAsHtml(signingPrivateKeyPem: str, postHtml = '' if boxName != 'tlmedia': + reactionStr = '' + if showIcons: + reactionStr = htmlEmojiReactions(postJsonObject, True, personUrl) + if postIsSensitive and reactionStr: + reactionStr = '
' + reactionStr postHtml = '
\n' postHtml += avatarImageInPost postHtml += '
\n' + \ ' ' + titleStr + \ replyAvatarImageInPost + '
\n' - postHtml += contentStr + citationsStr + footerStr + '\n' + postHtml += contentStr + citationsStr + reactionStr + footerStr + '\n' postHtml += '
\n' else: postHtml = galleryStr From af69491fe94015cedc8bdf6ba6c2291dfd9fdd49 Mon Sep 17 00:00:00 2001 From: Bob Mottram Date: Wed, 10 Nov 2021 17:20:59 +0000 Subject: [PATCH 11/40] Limit the number of reaction types which can be shown on a post --- reaction.py | 5 +++-- webapp_post.py | 8 +++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/reaction.py b/reaction.py index f364cabd2..531f41ac0 100644 --- a/reaction.py +++ b/reaction.py @@ -502,7 +502,7 @@ def updateReactionCollection(recentPostsCache: {}, def htmlEmojiReactions(postJsonObject: {}, interactive: bool, - actor: str) -> str: + actor: str, maxReactionTypes: int) -> str: """html containing row of emoji reactions """ if not hasObjectDict(postJsonObject): @@ -515,7 +515,8 @@ def htmlEmojiReactions(postJsonObject: {}, interactive: bool, for item in postJsonObject['object']['reactions']['items']: emojiContent = item['content'] if not reactions.get(emojiContent): - reactions[emojiContent] = 1 + if len(reactions.items()) < maxReactionTypes: + reactions[emojiContent] = 1 else: reactions[emojiContent] += 1 if len(reactions.items()) == 0: diff --git a/webapp_post.py b/webapp_post.py index fb32d3d56..9bdb02f26 100644 --- a/webapp_post.py +++ b/webapp_post.py @@ -1304,6 +1304,10 @@ def individualPostAsHtml(signingPrivateKeyPem: str, if not postJsonObject: return '' + # maximum number of different emoji reactions which can + # be added to a post + maxReactionTypes = 5 + # benchmark postStartTime = time.time() @@ -1882,7 +1886,9 @@ def individualPostAsHtml(signingPrivateKeyPem: str, if boxName != 'tlmedia': reactionStr = '' if showIcons: - reactionStr = htmlEmojiReactions(postJsonObject, True, personUrl) + reactionStr = \ + htmlEmojiReactions(postJsonObject, True, personUrl, + maxReactionTypes) if postIsSensitive and reactionStr: reactionStr = '
' + reactionStr postHtml = '
\n' for emojiContent, count in reactions.items(): htmlStr += '
\n' diff --git a/scripts/epicyon-notification b/scripts/epicyon-notification index a92db2b01..d764e4771 100755 --- a/scripts/epicyon-notification +++ b/scripts/epicyon-notification @@ -215,6 +215,21 @@ function notifications { fi fi + # send notifications for emoji reactions to XMPP/email users + epicyonLikeFile="$epicyonDir/.newReaction" + if [ -f "$epicyonReactionFile" ]; then + if ! grep -q "##sent##" "$epicyonReactionFile"; then + epicyonReactionMessage=$(notification_translate_text 'Reaction by') + epicyonReactionFileContent=$(cat "$epicyonReactionFile" | awk -F ' ' '{print $1}')" "$(echo "$epicyonReactionMessage")" "$(cat "$epicyonReactionFile" | awk -F ' ' '{print $2}') + if [[ "$epicyonReactionFileContent" == *':'* ]]; then + epicyonReactionMessage="Epicyon: $epicyonReactionFileContent" + fi + sendNotification "$USERNAME" "Epicyon" "$epicyonReactionMessage" + echo "##sent##" > "$epicyonReactionFile" + chown ${PROJECT_NAME}:${PROJECT_NAME} "$epicyonReactionFile" + fi + fi + # send notifications for posts arriving from a particular person epicyonNotifyFile="$epicyonDir/.newNotifiedPost" if [ -f "$epicyonNotifyFile" ]; then diff --git a/translations/ar.json b/translations/ar.json index 53e3d7da9..33121d5c1 100644 --- a/translations/ar.json +++ b/translations/ar.json @@ -492,5 +492,6 @@ "Add content warnings for the following sites": "أضف تحذيرات المحتوى للمواقع التالية", "Known Web Crawlers": "برامج زحف الويب المعروفة", "Add to the calendar": "أضف إلى التقويم", - "Content License": "ترخيص المحتوى" + "Content License": "ترخيص المحتوى", + "Reaction by": "رد فعل" } diff --git a/translations/ca.json b/translations/ca.json index fb0b7340d..a9771ffd7 100644 --- a/translations/ca.json +++ b/translations/ca.json @@ -492,5 +492,6 @@ "Add content warnings for the following sites": "Afegiu advertiments de contingut per als llocs següents", "Known Web Crawlers": "Exploradors web coneguts", "Add to the calendar": "Afegeix al calendari", - "Content License": "Llicència de contingut" + "Content License": "Llicència de contingut", + "Reaction by": "Reacció de" } diff --git a/translations/cy.json b/translations/cy.json index d1eaf3d76..0415d6e30 100644 --- a/translations/cy.json +++ b/translations/cy.json @@ -492,5 +492,6 @@ "Add content warnings for the following sites": "Ychwanegwch rybuddion cynnwys ar gyfer y gwefannau canlynol", "Known Web Crawlers": "Crawlers Gwe Hysbys", "Add to the calendar": "Ychwanegwch at y calendr", - "Content License": "Trwydded Cynnwys" + "Content License": "Trwydded Cynnwys", + "Reaction by": "Ymateb gan" } diff --git a/translations/de.json b/translations/de.json index a94268597..1663e535d 100644 --- a/translations/de.json +++ b/translations/de.json @@ -492,5 +492,6 @@ "Add content warnings for the following sites": "Inhaltswarnungen für die folgenden Websites hinzufügen", "Known Web Crawlers": "Bekannte Web-Crawler", "Add to the calendar": "Zum Kalender hinzufügen", - "Content License": "Inhaltslizenz" + "Content License": "Inhaltslizenz", + "Reaction by": "Reaktion von" } diff --git a/translations/en.json b/translations/en.json index d96083f0b..627ea5a17 100644 --- a/translations/en.json +++ b/translations/en.json @@ -492,5 +492,6 @@ "Add content warnings for the following sites": "Add content warnings for the following sites", "Known Web Crawlers": "Known Web Crawlers", "Add to the calendar": "Add to the calendar", - "Content License": "Content License" + "Content License": "Content License", + "Reaction by": "Reaction by" } diff --git a/translations/es.json b/translations/es.json index 00a945f28..5dc843317 100644 --- a/translations/es.json +++ b/translations/es.json @@ -492,5 +492,6 @@ "Add content warnings for the following sites": "Agregue advertencias de contenido para los siguientes sitios", "Known Web Crawlers": "Rastreadores web conocidos", "Add to the calendar": "Agregar al calendario", - "Content License": "Licencia de contenido" + "Content License": "Licencia de contenido", + "Reaction by": "Reacción de" } diff --git a/translations/fr.json b/translations/fr.json index 0d082c4a9..b8ec4730a 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -492,5 +492,6 @@ "Add content warnings for the following sites": "Ajouter des avertissements de contenu pour les sites suivants", "Known Web Crawlers": "Crawlers Web connus", "Add to the calendar": "Ajouter au calendrier", - "Content License": "Licence de contenu" + "Content License": "Licence de contenu", + "Reaction by": "Réaction par" } diff --git a/translations/ga.json b/translations/ga.json index 1d91c0471..f79bf3a30 100644 --- a/translations/ga.json +++ b/translations/ga.json @@ -492,5 +492,6 @@ "Add content warnings for the following sites": "Cuir rabhaidh ábhair leis na suíomhanna seo a leanas", "Known Web Crawlers": "Crawlers Gréasáin Aitheanta", "Add to the calendar": "Cuir leis an bhféilire", - "Content License": "Ceadúnas Ábhar" + "Content License": "Ceadúnas Ábhar", + "Reaction by": "Imoibriú le" } diff --git a/translations/hi.json b/translations/hi.json index 93a81c7e6..965c8281c 100644 --- a/translations/hi.json +++ b/translations/hi.json @@ -492,5 +492,6 @@ "Add content warnings for the following sites": "निम्नलिखित साइटों के लिए सामग्री चेतावनियाँ जोड़ें", "Known Web Crawlers": "ज्ञात वेब क्रॉलर", "Add to the calendar": "कैलेंडर में जोड़ें", - "Content License": "सामग्री लाइसेंस" + "Content License": "सामग्री लाइसेंस", + "Reaction by": "द्वारा प्रतिक्रिया" } diff --git a/translations/it.json b/translations/it.json index 64319f34a..427a9c055 100644 --- a/translations/it.json +++ b/translations/it.json @@ -492,5 +492,6 @@ "Add content warnings for the following sites": "Aggiungi avvisi sui contenuti per i seguenti siti", "Known Web Crawlers": "Crawler Web conosciuti", "Add to the calendar": "Aggiungi al calendario", - "Content License": "Licenza sui contenuti" + "Content License": "Licenza sui contenuti", + "Reaction by": "Reazione di" } diff --git a/translations/ja.json b/translations/ja.json index 89b880f99..f204374f7 100644 --- a/translations/ja.json +++ b/translations/ja.json @@ -492,5 +492,6 @@ "Add content warnings for the following sites": "次のサイトのコンテンツ警告を追加します", "Known Web Crawlers": "既知のWebクローラー", "Add to the calendar": "カレンダーに追加", - "Content License": "コンテンツライセンス" + "Content License": "コンテンツライセンス", + "Reaction by": "による反応" } diff --git a/translations/ku.json b/translations/ku.json index 54edfdbb8..880e0bc96 100644 --- a/translations/ku.json +++ b/translations/ku.json @@ -492,5 +492,6 @@ "Add content warnings for the following sites": "Ji bo malperên jêrîn hişyariyên naverokê zêde bikin", "Known Web Crawlers": "Crawlerên Webê yên naskirî", "Add to the calendar": "Di salnameyê de zêde bike", - "Content License": "Naverok License de" + "Content License": "Naverok License de", + "Reaction by": "Reaction by" } diff --git a/translations/oc.json b/translations/oc.json index 8240c4a8e..9e2c9cce8 100644 --- a/translations/oc.json +++ b/translations/oc.json @@ -488,5 +488,6 @@ "Add content warnings for the following sites": "Add content warnings for the following sites", "Known Web Crawlers": "Known Web Crawlers", "Add to the calendar": "Add to the calendar", - "Content License": "Content License" + "Content License": "Content License", + "Reaction by": "Reaction by" } diff --git a/translations/pt.json b/translations/pt.json index 958a40f6f..054183025 100644 --- a/translations/pt.json +++ b/translations/pt.json @@ -492,5 +492,6 @@ "Add content warnings for the following sites": "Adicione avisos de conteúdo para os seguintes sites", "Known Web Crawlers": "Rastreadores da Web conhecidos", "Add to the calendar": "Adicionar ao calendário", - "Content License": "Licença de Conteúdo" + "Content License": "Licença de Conteúdo", + "Reaction by": "Reazione di" } diff --git a/translations/ru.json b/translations/ru.json index d59bf4869..ca46b77e9 100644 --- a/translations/ru.json +++ b/translations/ru.json @@ -492,5 +492,6 @@ "Add content warnings for the following sites": "Добавить предупреждения о содержании для следующих сайтов", "Known Web Crawlers": "Известные веб-сканеры", "Add to the calendar": "Добавить в календарь", - "Content License": "Лицензия на содержание" + "Content License": "Лицензия на содержание", + "Reaction by": "Реакция со стороны" } diff --git a/translations/sw.json b/translations/sw.json index d4a5ca713..dfaf4d7a3 100644 --- a/translations/sw.json +++ b/translations/sw.json @@ -492,5 +492,6 @@ "Add content warnings for the following sites": "Ongeza maonyo ya yaliyomo kwa wavuti zifuatazo", "Known Web Crawlers": "Watambaji Wavuti Wanaojulikana", "Add to the calendar": "Ongeza kwenye kalenda", - "Content License": "Leseni ya Maudhui" + "Content License": "Leseni ya Maudhui", + "Reaction by": "Majibu kwa" } diff --git a/translations/zh.json b/translations/zh.json index a698fb5ce..fe5b970a1 100644 --- a/translations/zh.json +++ b/translations/zh.json @@ -492,5 +492,6 @@ "Add content warnings for the following sites": "为以下网站添加内容警告", "Known Web Crawlers": "已知的网络爬虫", "Add to the calendar": "添加到日历", - "Content License": "内容许可" + "Content License": "内容许可", + "Reaction by": "反应由" } diff --git a/webapp_post.py b/webapp_post.py index ad71f54ff..9987e07f0 100644 --- a/webapp_post.py +++ b/webapp_post.py @@ -1925,6 +1925,7 @@ def htmlIndividualPost(cssCache: {}, nickname: str, domain: str, port: int, authorized: bool, postJsonObject: {}, httpPrefix: str, projectVersion: str, likedBy: str, + reactBy: str, reactEmoji: str, YTReplacementDomain: str, twitterReplacementDomain: str, showPublishedDateOnly: bool, @@ -1937,17 +1938,27 @@ def htmlIndividualPost(cssCache: {}, """ originalPostJson = postJsonObject postStr = '' + byStr = '' + byText = '' + byTextExtra = '' if likedBy: - likedByNickname = getNicknameFromActor(likedBy) - likedByDomain, likedByPort = getDomainFromActor(likedBy) - likedByDomain = getFullDomain(likedByDomain, likedByPort) - likedByHandle = likedByNickname + '@' + likedByDomain - likedByStr = 'Liked by' - if translate.get(likedByStr): - likedByStr = translate[likedByStr] + byStr = likedBy + byText = 'Liked by' + elif reactBy and reactEmoji: + byStr = reactBy + byText = 'Reaction by' + byTextExtra = ' ' + reactEmoji + + if byStr: + byStrNickname = getNicknameFromActor(byStr) + byStrDomain, byStrPort = getDomainFromActor(byStr) + byStrDomain = getFullDomain(byStrDomain, byStrPort) + byStrHandle = byStrNickname + '@' + byStrDomain + if translate.get(byText): + byText = translate[byText] postStr += \ - '

' + likedByStr + ' @' + \ - likedByHandle + '\n' + '

' + byText + ' @' + \ + byStrHandle + '' + byTextExtra + '\n' domainFull = getFullDomain(domain, port) actor = '/users/' + nickname @@ -1957,8 +1968,8 @@ def htmlIndividualPost(cssCache: {}, ' \n' followStr += \ ' \n' - if not isFollowingActor(baseDir, nickname, domainFull, likedBy): + byStrHandle + '">\n' + if not isFollowingActor(baseDir, nickname, domainFull, byStr): translateFollowStr = 'Follow' if translate.get(translateFollowStr): translateFollowStr = translate[translateFollowStr] From c7d0f6689336b94448b3945414bf17e34899dd47 Mon Sep 17 00:00:00 2001 From: Bob Mottram Date: Wed, 10 Nov 2021 19:33:28 +0000 Subject: [PATCH 14/40] Option to enable emoji reaction notifications --- daemon.py | 26 ++++++++++++++++++++++++++ inbox.py | 6 +++--- person.py | 7 +++++++ translations/ar.json | 3 ++- translations/ca.json | 3 ++- translations/cy.json | 3 ++- translations/de.json | 3 ++- translations/en.json | 3 ++- translations/es.json | 3 ++- translations/fr.json | 3 ++- translations/ga.json | 3 ++- translations/hi.json | 3 ++- translations/it.json | 3 ++- translations/ja.json | 3 ++- translations/ku.json | 3 ++- translations/oc.json | 3 ++- translations/pt.json | 3 ++- translations/ru.json | 3 ++- translations/sw.json | 3 ++- translations/zh.json | 3 ++- webapp_profile.py | 13 ++++++++++--- 21 files changed, 80 insertions(+), 23 deletions(-) diff --git a/daemon.py b/daemon.py index 03e142f31..8eff2e7bf 100644 --- a/daemon.py +++ b/daemon.py @@ -5708,6 +5708,32 @@ class PubServer(BaseHTTPRequestHandler): notifyLikesFilename) pass + notifyReactionsFilename = \ + acctDir(baseDir, nickname, domain) + \ + '/.notifyReactions' + if onFinalWelcomeScreen: + # default setting from welcome screen + with open(notifyReactionsFilename, 'w+') as rFile: + rFile.write('\n') + actorChanged = True + else: + notifyReactionsActive = False + if fields.get('notifyReactions'): + if fields['notifyReactions'] == 'on': + notifyReactionsActive = True + with open(notifyReactionsFilename, + 'w+') as rFile: + rFile.write('\n') + if not notifyReactionsActive: + if os.path.isfile(notifyReactionsFilename): + try: + os.remove(notifyReactionsFilename) + except BaseException: + print('EX: _profileUpdate ' + + 'unable to delete ' + + notifyReactionsFilename) + pass + # this account is a bot if fields.get('isBot'): if fields['isBot'] == 'on': diff --git a/inbox.py b/inbox.py index 8d45aeff4..a2bbdf81d 100644 --- a/inbox.py +++ b/inbox.py @@ -2460,7 +2460,7 @@ def _reactionNotify(baseDir: str, domain: str, onionDomain: str, accountDir = baseDir + '/accounts/' + handle # are reaction notifications enabled? - notifyReactionEnabledFilename = accountDir + '/.notifyReaction' + notifyReactionEnabledFilename = accountDir + '/.notifyReactions' if not os.path.isfile(notifyReactionEnabledFilename): return @@ -2479,8 +2479,8 @@ def _reactionNotify(baseDir: str, domain: str, onionDomain: str, reactionHandle = actor if reactionHandle != handle: reactionStr = \ - reactionHandle + ' ' + url + '?reactionBy=' + actor + \ - ';emoji=' + emojiContent + reactionHandle + ' ' + url + '?reactBy=' + actor + \ + ';emoj=' + emojiContent prevReactionFile = accountDir + '/.prevReaction' # was there a previous reaction notification? if os.path.isfile(prevReactionFile): diff --git a/person.py b/person.py index 3594dd849..ada52381e 100644 --- a/person.py +++ b/person.py @@ -635,6 +635,13 @@ def createPerson(baseDir: str, nickname: str, domain: str, port: int, with open(notifyLikesFilename, 'w+') as nFile: nFile.write('\n') + # notify when posts have emoji reactions + if nickname != 'news': + notifyReactionsFilename = \ + acctDir(baseDir, nickname, domain) + '/.notifyReactions' + with open(notifyReactionsFilename, 'w+') as nFile: + nFile.write('\n') + theme = getConfigParam(baseDir, 'theme') if not theme: theme = 'default' diff --git a/translations/ar.json b/translations/ar.json index 33121d5c1..ce4f2ee73 100644 --- a/translations/ar.json +++ b/translations/ar.json @@ -493,5 +493,6 @@ "Known Web Crawlers": "برامج زحف الويب المعروفة", "Add to the calendar": "أضف إلى التقويم", "Content License": "ترخيص المحتوى", - "Reaction by": "رد فعل" + "Reaction by": "رد فعل", + "Notify on emoji reactions": "يخطر على ردود الفعل الرموز التعبيرية" } diff --git a/translations/ca.json b/translations/ca.json index a9771ffd7..4137bd30c 100644 --- a/translations/ca.json +++ b/translations/ca.json @@ -493,5 +493,6 @@ "Known Web Crawlers": "Exploradors web coneguts", "Add to the calendar": "Afegeix al calendari", "Content License": "Llicència de contingut", - "Reaction by": "Reacció de" + "Reaction by": "Reacció de", + "Notify on emoji reactions": "Notificar sobre les reaccions dels emojis" } diff --git a/translations/cy.json b/translations/cy.json index 0415d6e30..cdbac2d5f 100644 --- a/translations/cy.json +++ b/translations/cy.json @@ -493,5 +493,6 @@ "Known Web Crawlers": "Crawlers Gwe Hysbys", "Add to the calendar": "Ychwanegwch at y calendr", "Content License": "Trwydded Cynnwys", - "Reaction by": "Ymateb gan" + "Reaction by": "Ymateb gan", + "Notify on emoji reactions": "Hysbysu ar ymatebion emoji" } diff --git a/translations/de.json b/translations/de.json index 1663e535d..04816a5fb 100644 --- a/translations/de.json +++ b/translations/de.json @@ -493,5 +493,6 @@ "Known Web Crawlers": "Bekannte Web-Crawler", "Add to the calendar": "Zum Kalender hinzufügen", "Content License": "Inhaltslizenz", - "Reaction by": "Reaktion von" + "Reaction by": "Reaktion von", + "Notify on emoji reactions": "Bei Emoji-Reaktionen benachrichtigen" } diff --git a/translations/en.json b/translations/en.json index 627ea5a17..7e0000453 100644 --- a/translations/en.json +++ b/translations/en.json @@ -493,5 +493,6 @@ "Known Web Crawlers": "Known Web Crawlers", "Add to the calendar": "Add to the calendar", "Content License": "Content License", - "Reaction by": "Reaction by" + "Reaction by": "Reaction by", + "Notify on emoji reactions": "Notify on emoji reactions" } diff --git a/translations/es.json b/translations/es.json index 5dc843317..614f8a2d0 100644 --- a/translations/es.json +++ b/translations/es.json @@ -493,5 +493,6 @@ "Known Web Crawlers": "Rastreadores web conocidos", "Add to the calendar": "Agregar al calendario", "Content License": "Licencia de contenido", - "Reaction by": "Reacción de" + "Reaction by": "Reacción de", + "Notify on emoji reactions": "Notificar sobre reacciones emoji" } diff --git a/translations/fr.json b/translations/fr.json index b8ec4730a..c61206e78 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -493,5 +493,6 @@ "Known Web Crawlers": "Crawlers Web connus", "Add to the calendar": "Ajouter au calendrier", "Content License": "Licence de contenu", - "Reaction by": "Réaction par" + "Reaction by": "Réaction par", + "Notify on emoji reactions": "Avertir sur les réactions emoji" } diff --git a/translations/ga.json b/translations/ga.json index f79bf3a30..89aceba59 100644 --- a/translations/ga.json +++ b/translations/ga.json @@ -493,5 +493,6 @@ "Known Web Crawlers": "Crawlers Gréasáin Aitheanta", "Add to the calendar": "Cuir leis an bhféilire", "Content License": "Ceadúnas Ábhar", - "Reaction by": "Imoibriú le" + "Reaction by": "Imoibriú le", + "Notify on emoji reactions": "Fógra a thabhairt faoi imoibrithe emoji" } diff --git a/translations/hi.json b/translations/hi.json index 965c8281c..eac0c2e41 100644 --- a/translations/hi.json +++ b/translations/hi.json @@ -493,5 +493,6 @@ "Known Web Crawlers": "ज्ञात वेब क्रॉलर", "Add to the calendar": "कैलेंडर में जोड़ें", "Content License": "सामग्री लाइसेंस", - "Reaction by": "द्वारा प्रतिक्रिया" + "Reaction by": "द्वारा प्रतिक्रिया", + "Notify on emoji reactions": "इमोजी प्रतिक्रियाओं पर सूचित करें" } diff --git a/translations/it.json b/translations/it.json index 427a9c055..c54113472 100644 --- a/translations/it.json +++ b/translations/it.json @@ -493,5 +493,6 @@ "Known Web Crawlers": "Crawler Web conosciuti", "Add to the calendar": "Aggiungi al calendario", "Content License": "Licenza sui contenuti", - "Reaction by": "Reazione di" + "Reaction by": "Reazione di", + "Notify on emoji reactions": "Notifica sulle reazioni emoji" } diff --git a/translations/ja.json b/translations/ja.json index f204374f7..867f4dad0 100644 --- a/translations/ja.json +++ b/translations/ja.json @@ -493,5 +493,6 @@ "Known Web Crawlers": "既知のWebクローラー", "Add to the calendar": "カレンダーに追加", "Content License": "コンテンツライセンス", - "Reaction by": "による反応" + "Reaction by": "による反応", + "Notify on emoji reactions": "絵文字の反応を通知する" } diff --git a/translations/ku.json b/translations/ku.json index 880e0bc96..e76ca988f 100644 --- a/translations/ku.json +++ b/translations/ku.json @@ -493,5 +493,6 @@ "Known Web Crawlers": "Crawlerên Webê yên naskirî", "Add to the calendar": "Di salnameyê de zêde bike", "Content License": "Naverok License de", - "Reaction by": "Reaction by" + "Reaction by": "Reaction by", + "Notify on emoji reactions": "Li ser reaksiyonên emoji agahdar bikin" } diff --git a/translations/oc.json b/translations/oc.json index 9e2c9cce8..072298e3d 100644 --- a/translations/oc.json +++ b/translations/oc.json @@ -489,5 +489,6 @@ "Known Web Crawlers": "Known Web Crawlers", "Add to the calendar": "Add to the calendar", "Content License": "Content License", - "Reaction by": "Reaction by" + "Reaction by": "Reaction by", + "Notify on emoji reactions": "Notify on emoji reactions" } diff --git a/translations/pt.json b/translations/pt.json index 054183025..710b9216a 100644 --- a/translations/pt.json +++ b/translations/pt.json @@ -493,5 +493,6 @@ "Known Web Crawlers": "Rastreadores da Web conhecidos", "Add to the calendar": "Adicionar ao calendário", "Content License": "Licença de Conteúdo", - "Reaction by": "Reazione di" + "Reaction by": "Reazione di", + "Notify on emoji reactions": "Notificar sobre reações de emoji" } diff --git a/translations/ru.json b/translations/ru.json index ca46b77e9..adecffaab 100644 --- a/translations/ru.json +++ b/translations/ru.json @@ -493,5 +493,6 @@ "Known Web Crawlers": "Известные веб-сканеры", "Add to the calendar": "Добавить в календарь", "Content License": "Лицензия на содержание", - "Reaction by": "Реакция со стороны" + "Reaction by": "Реакция со стороны", + "Notify on emoji reactions": "Уведомлять о реакции на смайлики" } diff --git a/translations/sw.json b/translations/sw.json index dfaf4d7a3..d7a969e06 100644 --- a/translations/sw.json +++ b/translations/sw.json @@ -493,5 +493,6 @@ "Known Web Crawlers": "Watambaji Wavuti Wanaojulikana", "Add to the calendar": "Ongeza kwenye kalenda", "Content License": "Leseni ya Maudhui", - "Reaction by": "Majibu kwa" + "Reaction by": "Majibu kwa", + "Notify on emoji reactions": "Arifu kuhusu maitikio ya emoji" } diff --git a/translations/zh.json b/translations/zh.json index fe5b970a1..d2d07e431 100644 --- a/translations/zh.json +++ b/translations/zh.json @@ -493,5 +493,6 @@ "Known Web Crawlers": "已知的网络爬虫", "Add to the calendar": "添加到日历", "Content License": "内容许可", - "Reaction by": "反应由" + "Reaction by": "反应由", + "Notify on emoji reactions": "通知表情符号反应" } diff --git a/webapp_profile.py b/webapp_profile.py index 269851597..1b68e8b47 100644 --- a/webapp_profile.py +++ b/webapp_profile.py @@ -1867,7 +1867,8 @@ def _htmlEditProfileOptions(isAdmin: bool, manuallyApprovesFollowers: str, isBot: str, isGroup: str, followDMs: str, removeTwitter: str, - notifyLikes: str, hideLikeButton: str, + notifyLikes: str, notifyReactions: str, + hideLikeButton: str, translate: {}) -> str: """option checkboxes section of edit profile screen """ @@ -1891,6 +1892,9 @@ def _htmlEditProfileOptions(isAdmin: bool, editProfileForm += \ editCheckBox(translate['Notify when posts are liked'], 'notifyLikes', notifyLikes) + editProfileForm += \ + editCheckBox(translate['Notify on emoji reactions'], + 'notifyReactions', notifyReactions) editProfileForm += \ editCheckBox(translate["Don't show the Like button"], 'hideLikeButton', hideLikeButton) @@ -2048,7 +2052,7 @@ def htmlEditProfile(cssCache: {}, translate: {}, baseDir: str, path: str, displayNickname = nickname isBot = isGroup = followDMs = removeTwitter = '' - notifyLikes = hideLikeButton = mediaInstanceStr = '' + notifyLikes = notifyReactions = hideLikeButton = mediaInstanceStr = '' blogsInstanceStr = newsInstanceStr = movedTo = twitterStr = '' bioStr = donateUrl = websiteUrl = emailAddress = PGPpubKey = '' PGPfingerprint = xmppAddress = matrixAddress = '' @@ -2099,6 +2103,8 @@ def htmlEditProfile(cssCache: {}, translate: {}, baseDir: str, path: str, removeTwitter = 'checked' if os.path.isfile(accountDir + '/.notifyLikes'): notifyLikes = 'checked' + if os.path.isfile(accountDir + '/.notifyReactions'): + notifyReactions = 'checked' if os.path.isfile(accountDir + '/.hideLikeButton'): hideLikeButton = 'checked' @@ -2196,7 +2202,8 @@ def htmlEditProfile(cssCache: {}, translate: {}, baseDir: str, path: str, editProfileForm += \ _htmlEditProfileOptions(isAdmin, manuallyApprovesFollowers, isBot, isGroup, followDMs, removeTwitter, - notifyLikes, hideLikeButton, translate) + notifyLikes, notifyReactions, + hideLikeButton, translate) # Contact information editProfileForm += \ From 5a06cf762a0e955f0911bb7e4bdbb27791e07260 Mon Sep 17 00:00:00 2001 From: Bob Mottram Date: Wed, 10 Nov 2021 21:43:48 +0000 Subject: [PATCH 15/40] Emoji reaction icon links --- daemon.py | 412 +++++++++++++++++++++++++++++++++++++++++++++++++++- reaction.py | 5 +- 2 files changed, 413 insertions(+), 4 deletions(-) diff --git a/daemon.py b/daemon.py index 8eff2e7bf..89609c8dc 100644 --- a/daemon.py +++ b/daemon.py @@ -238,6 +238,8 @@ from categories import updateHashtagCategories from languages import getActorLanguages from languages import setActorLanguages from like import updateLikesCollection +from reaction import updateReactionCollection +from utils import undoReactionCollectionEntry from utils import getNewPostEndpoints from utils import malformedCiphertext from utils import hasActor @@ -7895,6 +7897,375 @@ class PubServer(BaseHTTPRequestHandler): '_GET', '_undoLikeButton', self.server.debug) + def _reactionButton(self, callingDomain: str, path: str, + baseDir: str, httpPrefix: str, + domain: str, domainFull: str, + onionDomain: str, i2pDomain: str, + GETstartTime, + proxyType: str, cookie: str, + debug: str): + """Press an emoji reaction button + """ + pageNumber = 1 + reactionUrl = path.split('?react=')[1] + if '?' in reactionUrl: + reactionUrl = reactionUrl.split('?')[0] + timelineBookmark = '' + if '?bm=' in path: + timelineBookmark = path.split('?bm=')[1] + if '?' in timelineBookmark: + timelineBookmark = timelineBookmark.split('?')[0] + timelineBookmark = '#' + timelineBookmark + actor = path.split('?react=')[0] + if '?page=' in path: + pageNumberStr = path.split('?page=')[1] + if '?' in pageNumberStr: + pageNumberStr = pageNumberStr.split('?')[0] + if '#' in pageNumberStr: + pageNumberStr = pageNumberStr.split('#')[0] + if pageNumberStr.isdigit(): + pageNumber = int(pageNumberStr) + timelineStr = 'inbox' + if '?tl=' in path: + timelineStr = path.split('?tl=')[1] + if '?' in timelineStr: + timelineStr = timelineStr.split('?')[0] + emojiContentEncoded = None + if '?emojreact=' in path: + emojiContentEncoded = path.split('?emojreact=')[1] + if '?' in emojiContentEncoded: + emojiContentEncoded = emojiContentEncoded.split('?')[0] + if not emojiContentEncoded: + print('WARN: no emoji reaction ' + actor) + self.server.GETbusy = False + actorAbsolute = self._getInstanceUrl(callingDomain) + actor + actorPathStr = \ + actorAbsolute + '/' + timelineStr + \ + '?page=' + str(pageNumber) + timelineBookmark + self._redirect_headers(actorPathStr, cookie, + callingDomain) + return + emojiContent = urllib.parse.unquote_plus(emojiContentEncoded) + self.postToNickname = getNicknameFromActor(actor) + if not self.postToNickname: + print('WARN: unable to find nickname in ' + actor) + self.server.GETbusy = False + actorAbsolute = self._getInstanceUrl(callingDomain) + actor + actorPathStr = \ + actorAbsolute + '/' + timelineStr + \ + '?page=' + str(pageNumber) + timelineBookmark + self._redirect_headers(actorPathStr, cookie, + callingDomain) + return + if not self.server.session: + print('Starting new session during emoji reaction') + self.server.session = createSession(proxyType) + if not self.server.session: + print('ERROR: ' + + 'GET failed to create session during emoji reaction') + self._404() + self.server.GETbusy = False + return + reactionActor = \ + localActorUrl(httpPrefix, self.postToNickname, domainFull) + actorReaction = path.split('?actor=')[1] + if '?' in actorReaction: + actorReaction = actorReaction.split('?')[0] + + # if this is an announce then send the emoji reaction + # to the original post + origActor, origPostUrl, origFilename = \ + getOriginalPostFromAnnounceUrl(reactionUrl, baseDir, + self.postToNickname, domain) + reactionUrl2 = reactionUrl + reactionPostFilename = origFilename + if origActor and origPostUrl: + actorReaction = origActor + reactionUrl2 = origPostUrl + reactionPostFilename = None + + reactionJson = { + "@context": "https://www.w3.org/ns/activitystreams", + 'type': 'EmojiReact', + 'actor': reactionActor, + 'to': [actorReaction], + 'object': reactionUrl2, + 'content': emojiContent + } + + # send out the emoji reaction to followers + self._postToOutbox(reactionJson, self.server.projectVersion, None) + + fitnessPerformance(GETstartTime, self.server.fitness, + '_GET', '_reactionButton postToOutbox', + self.server.debug) + + print('Locating emoji reaction post ' + reactionUrl) + # directly emoji reaction the post file + if not reactionPostFilename: + reactionPostFilename = \ + locatePost(baseDir, self.postToNickname, domain, reactionUrl) + if reactionPostFilename: + recentPostsCache = self.server.recentPostsCache + reactionPostJson = loadJson(reactionPostFilename, 0, 1) + if origFilename and origPostUrl: + updateReactionCollection(recentPostsCache, + baseDir, reactionPostFilename, + reactionUrl, + reactionActor, self.postToNickname, + domain, debug, reactionPostJson, + emojiContent) + reactionUrl = origPostUrl + reactionPostFilename = origFilename + if debug: + print('Updating emoji reaction for ' + reactionPostFilename) + updateReactionCollection(recentPostsCache, + baseDir, reactionPostFilename, + reactionUrl, + reactionActor, + self.postToNickname, domain, + debug, None, emojiContent) + if debug: + print('Regenerating html post for changed ' + + 'emoji reaction collection') + # clear the icon from the cache so that it gets updated + if reactionPostJson: + cachedPostFilename = \ + getCachedPostFilename(baseDir, self.postToNickname, + domain, reactionPostJson) + if debug: + print('Reaction post json: ' + str(reactionPostJson)) + print('Reaction post nickname: ' + + self.postToNickname + ' ' + domain) + print('Reaction post cache: ' + str(cachedPostFilename)) + showIndividualPostIcons = True + manuallyApproveFollowers = \ + followerApprovalActive(baseDir, + self.postToNickname, domain) + showRepeats = not isDM(reactionPostJson) + individualPostAsHtml(self.server.signingPrivateKeyPem, False, + self.server.recentPostsCache, + self.server.maxRecentPosts, + self.server.translate, + pageNumber, baseDir, + self.server.session, + self.server.cachedWebfingers, + self.server.personCache, + self.postToNickname, domain, + self.server.port, reactionPostJson, + None, True, + self.server.allowDeletion, + httpPrefix, + self.server.projectVersion, + timelineStr, + self.server.YTReplacementDomain, + self.server.twitterReplacementDomain, + self.server.showPublishedDateOnly, + self.server.peertubeInstances, + self.server.allowLocalNetworkAccess, + self.server.themeName, + self.server.systemLanguage, + self.server.maxLikeCount, + showRepeats, + showIndividualPostIcons, + manuallyApproveFollowers, + False, True, False, + self.server.CWlists, + self.server.listsEnabled) + else: + print('WARN: Emoji reaction post not found: ' + + reactionPostFilename) + else: + print('WARN: unable to locate file for emoji reaction post ' + + reactionUrl) + + self.server.GETbusy = False + actorAbsolute = self._getInstanceUrl(callingDomain) + actor + actorPathStr = \ + actorAbsolute + '/' + timelineStr + \ + '?page=' + str(pageNumber) + timelineBookmark + self._redirect_headers(actorPathStr, cookie, + callingDomain) + fitnessPerformance(GETstartTime, self.server.fitness, + '_GET', '_reactionButton', + self.server.debug) + + def _undoReactionButton(self, callingDomain: str, path: str, + baseDir: str, httpPrefix: str, + domain: str, domainFull: str, + onionDomain: str, i2pDomain: str, + GETstartTime, + proxyType: str, cookie: str, + debug: str): + """A button is pressed to undo emoji reaction + """ + pageNumber = 1 + reactionUrl = path.split('?unreact=')[1] + if '?' in reactionUrl: + reactionUrl = reactionUrl.split('?')[0] + timelineBookmark = '' + if '?bm=' in path: + timelineBookmark = path.split('?bm=')[1] + if '?' in timelineBookmark: + timelineBookmark = timelineBookmark.split('?')[0] + timelineBookmark = '#' + timelineBookmark + if '?page=' in path: + pageNumberStr = path.split('?page=')[1] + if '?' in pageNumberStr: + pageNumberStr = pageNumberStr.split('?')[0] + if '#' in pageNumberStr: + pageNumberStr = pageNumberStr.split('#')[0] + if pageNumberStr.isdigit(): + pageNumber = int(pageNumberStr) + timelineStr = 'inbox' + if '?tl=' in path: + timelineStr = path.split('?tl=')[1] + if '?' in timelineStr: + timelineStr = timelineStr.split('?')[0] + actor = path.split('?unreact=')[0] + self.postToNickname = getNicknameFromActor(actor) + if not self.postToNickname: + print('WARN: unable to find nickname in ' + actor) + self.server.GETbusy = False + actorAbsolute = self._getInstanceUrl(callingDomain) + actor + actorPathStr = \ + actorAbsolute + '/' + timelineStr + \ + '?page=' + str(pageNumber) + self._redirect_headers(actorPathStr, cookie, + callingDomain) + return + emojiContentEncoded = None + if '?emojreact=' in path: + emojiContentEncoded = path.split('?emojreact=')[1] + if '?' in emojiContentEncoded: + emojiContentEncoded = emojiContentEncoded.split('?')[0] + if not emojiContentEncoded: + print('WARN: no emoji reaction ' + actor) + self.server.GETbusy = False + actorAbsolute = self._getInstanceUrl(callingDomain) + actor + actorPathStr = \ + actorAbsolute + '/' + timelineStr + \ + '?page=' + str(pageNumber) + timelineBookmark + self._redirect_headers(actorPathStr, cookie, + callingDomain) + return + emojiContent = urllib.parse.unquote_plus(emojiContentEncoded) + if not self.server.session: + print('Starting new session during undo emoji reaction') + self.server.session = createSession(proxyType) + if not self.server.session: + print('ERROR: GET failed to create session ' + + 'during undo emoji reaction') + self._404() + self.server.GETbusy = False + return + undoActor = \ + localActorUrl(httpPrefix, self.postToNickname, domainFull) + actorReaction = path.split('?actor=')[1] + if '?' in actorReaction: + actorReaction = actorReaction.split('?')[0] + + # if this is an announce then send the emoji reaction + # to the original post + origActor, origPostUrl, origFilename = \ + getOriginalPostFromAnnounceUrl(reactionUrl, baseDir, + self.postToNickname, domain) + reactionUrl2 = reactionUrl + reactionPostFilename = origFilename + if origActor and origPostUrl: + actorReaction = origActor + reactionUrl2 = origPostUrl + reactionPostFilename = None + + undoReactionJson = { + "@context": "https://www.w3.org/ns/activitystreams", + 'type': 'Undo', + 'actor': undoActor, + 'to': [actorReaction], + 'object': { + 'type': 'EmojiReaction', + 'actor': undoActor, + 'to': [actorReaction], + 'object': reactionUrl2 + } + } + + # send out the undo emoji reaction to followers + self._postToOutbox(undoReactionJson, self.server.projectVersion, None) + + # directly undo the emoji reaction within the post file + if not reactionPostFilename: + reactionPostFilename = \ + locatePost(baseDir, self.postToNickname, domain, reactionUrl) + if reactionPostFilename: + recentPostsCache = self.server.recentPostsCache + reactionPostJson = loadJson(reactionPostFilename, 0, 1) + if origFilename and origPostUrl: + undoReactionCollectionEntry(recentPostsCache, + baseDir, reactionPostFilename, + reactionUrl, + undoActor, domain, debug, + reactionPostJson, + emojiContent) + reactionUrl = origPostUrl + reactionPostFilename = origFilename + if debug: + print('Removing emoji reaction for ' + reactionPostFilename) + undoReactionCollectionEntry(recentPostsCache, + baseDir, + reactionPostFilename, reactionUrl, + undoActor, domain, debug, None, + emojiContent) + if debug: + print('Regenerating html post for changed ' + + 'emoji reaction collection') + if reactionPostJson: + showIndividualPostIcons = True + manuallyApproveFollowers = \ + followerApprovalActive(baseDir, + self.postToNickname, domain) + showRepeats = not isDM(reactionPostJson) + individualPostAsHtml(self.server.signingPrivateKeyPem, False, + self.server.recentPostsCache, + self.server.maxRecentPosts, + self.server.translate, + pageNumber, baseDir, + self.server.session, + self.server.cachedWebfingers, + self.server.personCache, + self.postToNickname, domain, + self.server.port, reactionPostJson, + None, True, + self.server.allowDeletion, + httpPrefix, + self.server.projectVersion, timelineStr, + self.server.YTReplacementDomain, + self.server.twitterReplacementDomain, + self.server.showPublishedDateOnly, + self.server.peertubeInstances, + self.server.allowLocalNetworkAccess, + self.server.themeName, + self.server.systemLanguage, + self.server.maxLikeCount, + showRepeats, + showIndividualPostIcons, + manuallyApproveFollowers, + False, True, False, + self.server.CWlists, + self.server.listsEnabled) + else: + print('WARN: Unreaction post not found: ' + + reactionPostFilename) + self.server.GETbusy = False + actorAbsolute = self._getInstanceUrl(callingDomain) + actor + actorPathStr = \ + actorAbsolute + '/' + timelineStr + \ + '?page=' + str(pageNumber) + timelineBookmark + self._redirect_headers(actorPathStr, cookie, callingDomain) + fitnessPerformance(GETstartTime, self.server.fitness, + '_GET', '_undoReactionButton', + self.server.debug) + def _bookmarkButton(self, callingDomain: str, path: str, baseDir: str, httpPrefix: str, domain: str, domainFull: str, port: int, @@ -14411,7 +14782,7 @@ class PubServer(BaseHTTPRequestHandler): return fitnessPerformance(GETstartTime, self.server.fitness, - '_GET', 'like shown done', + '_GET', 'like button done', self.server.debug) # undo a like from the web interface icon @@ -14429,7 +14800,44 @@ class PubServer(BaseHTTPRequestHandler): return fitnessPerformance(GETstartTime, self.server.fitness, - '_GET', 'unlike shown done', + '_GET', 'unlike button done', + self.server.debug) + + # emoji reaction from the web interface icon + if authorized and htmlGET and '?react=' in self.path: + self._reactionButton(callingDomain, self.path, + self.server.baseDir, + self.server.httpPrefix, + self.server.domain, + self.server.domainFull, + self.server.onionDomain, + self.server.i2pDomain, + GETstartTime, + self.server.proxyType, + cookie, + self.server.debug) + return + + fitnessPerformance(GETstartTime, self.server.fitness, + '_GET', 'emoji reaction button done', + self.server.debug) + + # undo an emoji reaction from the web interface icon + if authorized and htmlGET and '?unreact=' in self.path: + self._undoReactionButton(callingDomain, self.path, + self.server.baseDir, + self.server.httpPrefix, + self.server.domain, + self.server.domainFull, + self.server.onionDomain, + self.server.i2pDomain, + GETstartTime, + self.server.proxyType, + cookie, self.server.debug) + return + + fitnessPerformance(GETstartTime, self.server.fitness, + '_GET', 'unreaction button done', self.server.debug) # bookmark from the web interface icon diff --git a/reaction.py b/reaction.py index a6ebc2978..099d27fa3 100644 --- a/reaction.py +++ b/reaction.py @@ -9,6 +9,7 @@ __module_group__ = "ActivityPub" import os import re +import urllib.parse from pprint import pprint from utils import hasObjectString from utils import hasObjectStringObject @@ -530,7 +531,7 @@ def htmlEmojiReactions(postJsonObject: {}, interactive: bool, if len(reactions.items()) == 0: return '' reactBy = removeIdEnding(postJsonObject['object']['id']) - baseUrl = actor + '?reactBy=' + reactBy + ';emoj=' + baseUrl = actor + '?react=' + reactBy + '?emojreact=' htmlStr = '

\n' for emojiContent, count in reactions.items(): htmlStr += '
\n' @@ -541,7 +542,7 @@ def htmlEmojiReactions(postJsonObject: {}, interactive: bool, emojiContentStr = emojiContent + countStr if interactive: # urlencode the emoji - emojiContentEncoded = emojiContent + emojiContentEncoded = urllib.parse.quote_plus(emojiContent) emojiContentStr = \ ' ' + \ emojiContentStr + '\n' From 862403054ab7e064aa2814422b6c856fcc010088 Mon Sep 17 00:00:00 2001 From: Bob Mottram Date: Wed, 10 Nov 2021 21:55:56 +0000 Subject: [PATCH 16/40] Reaction icon links react or unreact --- reaction.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/reaction.py b/reaction.py index 099d27fa3..09b6a8598 100644 --- a/reaction.py +++ b/reaction.py @@ -521,8 +521,13 @@ def htmlEmojiReactions(postJsonObject: {}, interactive: bool, if not postJsonObject['object']['reactions'].get('items'): return '' reactions = {} + reactedToByThisActor = [] for item in postJsonObject['object']['reactions']['items']: emojiContent = item['content'] + emojiActor = item['actor'] + if emojiActor == actor: + if emojiContent not in reactedToByThisActor: + reactedToByThisActor.append(emojiContent) if not reactions.get(emojiContent): if len(reactions.items()) < maxReactionTypes: reactions[emojiContent] = 1 @@ -531,9 +536,13 @@ def htmlEmojiReactions(postJsonObject: {}, interactive: bool, if len(reactions.items()) == 0: return '' reactBy = removeIdEnding(postJsonObject['object']['id']) - baseUrl = actor + '?react=' + reactBy + '?emojreact=' htmlStr = '
\n' for emojiContent, count in reactions.items(): + if emojiContent not in reactedToByThisActor: + baseUrl = actor + '?react=' + reactBy + '?emojreact=' + else: + baseUrl = actor + '?unreact=' + reactBy + '?emojreact=' + htmlStr += '
\n' if count < 100: countStr = str(count) From ec5245cd1c1bd1cdce89154fa0b9bf1b6740f403 Mon Sep 17 00:00:00 2001 From: Bob Mottram Date: Thu, 11 Nov 2021 10:35:05 +0000 Subject: [PATCH 17/40] Check for invalid characters in emoji reactions --- reaction.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/reaction.py b/reaction.py index 09b6a8598..536dbc06e 100644 --- a/reaction.py +++ b/reaction.py @@ -30,6 +30,7 @@ from utils import loadJson from utils import saveJson from utils import removePostFromCache from utils import getCachedPostFilename +from utils import containsInvalidChars from posts import sendSignedJson from session import postJson from webfinger import webfingerHandle @@ -54,6 +55,8 @@ def validEmojiContent(emojiContent: str) -> bool: return False if len(emojiRegex.findall(emojiContent)) == 0: return False + if containsInvalidChars(emojiContent): + return False return True From 1dc079f30aedeed6f8a25c6f54d601c1c5ecab0f Mon Sep 17 00:00:00 2001 From: Bob Mottram Date: Thu, 11 Nov 2021 10:37:20 +0000 Subject: [PATCH 18/40] Test for multi character emoji reaction --- tests.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests.py b/tests.py index 0855fa929..6e3641260 100644 --- a/tests.py +++ b/tests.py @@ -5914,6 +5914,7 @@ def _testValidEmojiContent() -> None: assert not validEmojiContent(None) assert not validEmojiContent(' ') assert not validEmojiContent('j') + assert not validEmojiContent('😀😀') assert validEmojiContent('😀') assert validEmojiContent('😄') From b784cd4d6aab0a41f1c8bac6c4f6736016f3084a Mon Sep 17 00:00:00 2001 From: Bob Mottram Date: Thu, 11 Nov 2021 15:02:03 +0000 Subject: [PATCH 19/40] Emoji reaction icons --- theme/blue/icons/reaction.png | Bin 0 -> 5541 bytes theme/debian/icons/reaction.png | Bin 0 -> 5541 bytes theme/default/icons/reaction.png | Bin 0 -> 5541 bytes theme/hacker/icons/reaction.png | Bin 0 -> 5118 bytes theme/indymediaclassic/icons/reaction.png | Bin 0 -> 5125 bytes theme/indymediamodern/icons/reaction.png | Bin 0 -> 5116 bytes theme/lcd/icons/reaction.png | Bin 0 -> 5123 bytes theme/light/icons/reaction.png | Bin 0 -> 5115 bytes theme/night/icons/reaction.png | Bin 0 -> 5541 bytes theme/pixel/icons/reaction.png | Bin 0 -> 6588 bytes theme/purple/icons/reaction.png | Bin 0 -> 5124 bytes theme/rc3/icons/reaction.png | Bin 0 -> 5122 bytes theme/solidaric/icons/reaction.png | Bin 0 -> 5118 bytes theme/starlight/icons/reaction.png | Bin 0 -> 5122 bytes theme/zen/icons/reaction.png | Bin 0 -> 5121 bytes webapp_post.py | 55 ++++++++++++++++++++-- 16 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 theme/blue/icons/reaction.png create mode 100644 theme/debian/icons/reaction.png create mode 100644 theme/default/icons/reaction.png create mode 100644 theme/hacker/icons/reaction.png create mode 100644 theme/indymediaclassic/icons/reaction.png create mode 100644 theme/indymediamodern/icons/reaction.png create mode 100644 theme/lcd/icons/reaction.png create mode 100644 theme/light/icons/reaction.png create mode 100644 theme/night/icons/reaction.png create mode 100644 theme/pixel/icons/reaction.png create mode 100644 theme/purple/icons/reaction.png create mode 100644 theme/rc3/icons/reaction.png create mode 100644 theme/solidaric/icons/reaction.png create mode 100644 theme/starlight/icons/reaction.png create mode 100644 theme/zen/icons/reaction.png diff --git a/theme/blue/icons/reaction.png b/theme/blue/icons/reaction.png new file mode 100644 index 0000000000000000000000000000000000000000..0279ca5df6ffae3aaaf9ba9b4f8e92de918b8ade GIT binary patch literal 5541 zcmeHLeOya@8$VO&J?jM_nkDJZ-fCOhYKm1_df!Bd&bG5zt!-_q4I%PUi%LRBLb|G} z@df|@7&E;+g5kZpW*eae|F-Jx^aB7L`EvGrMKGvDNo5kgPz0eA%cKwrQVID0pt^H! zUTEx7veCz$KgzVttct8sdK9{0t8nhjBB;k!@;D%Weg+e%EyV$~4XZyb?#9I>2ID7p zr&DZ`1MXL{Uj;t<#6IUTp|4=CbozsLEoV!|=&>dp=y01udw6x3(!PyR%ZdwRzb|Ue z9@|e@bJ@?dw<_85wX2C|+_cxlWAE=S>4)krpR!-EF^_hPrY?2WQIF;=tbsKw6KX{Td5wZQz+6e_^kMdIP#bjD84Zi4o1Ch}%dT-L<6_FNp zi^M^1pKZheC$$!*hL(w!0ddW|v(AS4KOud8S+7 z8EIQxa)HE!cB%VE_Md%jU3F8d>JW&P9=~&XZt-Iiv3}~=5xtWw3C|bFmsuGg(ODBg zqchf7eoeD?WHox+rq&bzdks8wm<(g&8B1ZMk3qX#Jj=W3V)irkc66`s#&p1Ri`Tg3 zl+oXPlAIsTi7ucsx< z-XmV?&dbMuFbqgN!{NfWo^JFC-j_i5i;p1UM!*W!${GJKknr_hgAdn90oRUh3CRkgwh)b4OnR+*;$Bw&V5B?H?6{HL5Lg z>!Tj2Kbg05T~}s(ozJ0S6Q2!dxf#14^k&H}2s36rif$i^3~w{uqmxzJ9wQJLdxhOuY_7g;I}c-YHgV57 zmR;5JmxJ~%W~aNE&$XEPW^CLmR&as(iQP5Zw-Z+8y>*~_~Dc4 zhb8lYiI_EYo=Gx?#%)AHYwdAmWwl z^$lGcH!Rp!p5Ad*u)2LNsn=>ps~ZMsZ80rAp?H&3ezo^Ocj`PV-Nsad10>s*A#ae? z)2SA*_sp_Lm1}}d>Ieo88kS_*6qjF!x{x0+<4ow{aJM%pSGB1ua$~Yxk7@rBkA;tP zN09{-U47!OaYQwE-gkP+)cCi-GOCQLPEs*?}g zUm$%cD0*_g^VrdZqbR*k@n=e@_GR8l9k%Po5Za^fR@y9$X(8Fe5hWy*@H-WIJqpByiQ?}zjKd}&-s6c*%3I1pA9 zC57*o0O0DTl7iewNP*%&0->0Weo=lNjS}+c=y@bRyq}Z-Efsn%mqD!M{%r2@NG_F! zcAH_~s-nRJQIG;esiH(;IZZ`J5Ao9AW6dxQjT(X|BI)S)et{^4LR4NYdh;wwrzzB?dnOFg; zFk-olhGLk*1Cn!PLa9P15u-GmAV(6Tprg^S9rZQ8D5;;{H+r%BiwdwFI29p3l{q)q(aF5F4j-EX)HtK{Fn&r{*Cu1^w-*lz%a_s zkLDra#%R)GdeG6D_%xn`E9B9J4oUE25XpEvh7Xd+7$VP+i{WrMJPZM%LOcqI%q4>0 z4^&LCTmg!?kcJ8-#|mK{k`vyMO5%bTGTs@&5J@}=2BbJrF;qO6;Kb!|C`5?!14V#L z2zMnY`Y|gF6%VH3QpkL~6OV)8kB8vjy z=@dqR0thD+3x;B71gFgmWYW=&Sp4^qKoO|m!wz(`uTUJL`fgwgqac<7)Tl`yJCVsm zJb_9kQV9eS@jEC4lF8vt)Nm5;SjSwUPz3w40Qc@T_^(3%!I?AL14z`F=&gZEy+h>yU}&z{5Wuc1OL$OM!SwUg zePn28f}9f4;%5jCbuc|?@%XETX2?ETgJY#SjesJhncd!X~o)hMyp4U^>#=BAVr-JE)`&U`g;`23SawQ DAJ=zF literal 0 HcmV?d00001 diff --git a/theme/debian/icons/reaction.png b/theme/debian/icons/reaction.png new file mode 100644 index 0000000000000000000000000000000000000000..0279ca5df6ffae3aaaf9ba9b4f8e92de918b8ade GIT binary patch literal 5541 zcmeHLeOya@8$VO&J?jM_nkDJZ-fCOhYKm1_df!Bd&bG5zt!-_q4I%PUi%LRBLb|G} z@df|@7&E;+g5kZpW*eae|F-Jx^aB7L`EvGrMKGvDNo5kgPz0eA%cKwrQVID0pt^H! zUTEx7veCz$KgzVttct8sdK9{0t8nhjBB;k!@;D%Weg+e%EyV$~4XZyb?#9I>2ID7p zr&DZ`1MXL{Uj;t<#6IUTp|4=CbozsLEoV!|=&>dp=y01udw6x3(!PyR%ZdwRzb|Ue z9@|e@bJ@?dw<_85wX2C|+_cxlWAE=S>4)krpR!-EF^_hPrY?2WQIF;=tbsKw6KX{Td5wZQz+6e_^kMdIP#bjD84Zi4o1Ch}%dT-L<6_FNp zi^M^1pKZheC$$!*hL(w!0ddW|v(AS4KOud8S+7 z8EIQxa)HE!cB%VE_Md%jU3F8d>JW&P9=~&XZt-Iiv3}~=5xtWw3C|bFmsuGg(ODBg zqchf7eoeD?WHox+rq&bzdks8wm<(g&8B1ZMk3qX#Jj=W3V)irkc66`s#&p1Ri`Tg3 zl+oXPlAIsTi7ucsx< z-XmV?&dbMuFbqgN!{NfWo^JFC-j_i5i;p1UM!*W!${GJKknr_hgAdn90oRUh3CRkgwh)b4OnR+*;$Bw&V5B?H?6{HL5Lg z>!Tj2Kbg05T~}s(ozJ0S6Q2!dxf#14^k&H}2s36rif$i^3~w{uqmxzJ9wQJLdxhOuY_7g;I}c-YHgV57 zmR;5JmxJ~%W~aNE&$XEPW^CLmR&as(iQP5Zw-Z+8y>*~_~Dc4 zhb8lYiI_EYo=Gx?#%)AHYwdAmWwl z^$lGcH!Rp!p5Ad*u)2LNsn=>ps~ZMsZ80rAp?H&3ezo^Ocj`PV-Nsad10>s*A#ae? z)2SA*_sp_Lm1}}d>Ieo88kS_*6qjF!x{x0+<4ow{aJM%pSGB1ua$~Yxk7@rBkA;tP zN09{-U47!OaYQwE-gkP+)cCi-GOCQLPEs*?}g zUm$%cD0*_g^VrdZqbR*k@n=e@_GR8l9k%Po5Za^fR@y9$X(8Fe5hWy*@H-WIJqpByiQ?}zjKd}&-s6c*%3I1pA9 zC57*o0O0DTl7iewNP*%&0->0Weo=lNjS}+c=y@bRyq}Z-Efsn%mqD!M{%r2@NG_F! zcAH_~s-nRJQIG;esiH(;IZZ`J5Ao9AW6dxQjT(X|BI)S)et{^4LR4NYdh;wwrzzB?dnOFg; zFk-olhGLk*1Cn!PLa9P15u-GmAV(6Tprg^S9rZQ8D5;;{H+r%BiwdwFI29p3l{q)q(aF5F4j-EX)HtK{Fn&r{*Cu1^w-*lz%a_s zkLDra#%R)GdeG6D_%xn`E9B9J4oUE25XpEvh7Xd+7$VP+i{WrMJPZM%LOcqI%q4>0 z4^&LCTmg!?kcJ8-#|mK{k`vyMO5%bTGTs@&5J@}=2BbJrF;qO6;Kb!|C`5?!14V#L z2zMnY`Y|gF6%VH3QpkL~6OV)8kB8vjy z=@dqR0thD+3x;B71gFgmWYW=&Sp4^qKoO|m!wz(`uTUJL`fgwgqac<7)Tl`yJCVsm zJb_9kQV9eS@jEC4lF8vt)Nm5;SjSwUPz3w40Qc@T_^(3%!I?AL14z`F=&gZEy+h>yU}&z{5Wuc1OL$OM!SwUg zePn28f}9f4;%5jCbuc|?@%XETX2?ETgJY#SjesJhncd!X~o)hMyp4U^>#=BAVr-JE)`&U`g;`23SawQ DAJ=zF literal 0 HcmV?d00001 diff --git a/theme/default/icons/reaction.png b/theme/default/icons/reaction.png new file mode 100644 index 0000000000000000000000000000000000000000..0279ca5df6ffae3aaaf9ba9b4f8e92de918b8ade GIT binary patch literal 5541 zcmeHLeOya@8$VO&J?jM_nkDJZ-fCOhYKm1_df!Bd&bG5zt!-_q4I%PUi%LRBLb|G} z@df|@7&E;+g5kZpW*eae|F-Jx^aB7L`EvGrMKGvDNo5kgPz0eA%cKwrQVID0pt^H! zUTEx7veCz$KgzVttct8sdK9{0t8nhjBB;k!@;D%Weg+e%EyV$~4XZyb?#9I>2ID7p zr&DZ`1MXL{Uj;t<#6IUTp|4=CbozsLEoV!|=&>dp=y01udw6x3(!PyR%ZdwRzb|Ue z9@|e@bJ@?dw<_85wX2C|+_cxlWAE=S>4)krpR!-EF^_hPrY?2WQIF;=tbsKw6KX{Td5wZQz+6e_^kMdIP#bjD84Zi4o1Ch}%dT-L<6_FNp zi^M^1pKZheC$$!*hL(w!0ddW|v(AS4KOud8S+7 z8EIQxa)HE!cB%VE_Md%jU3F8d>JW&P9=~&XZt-Iiv3}~=5xtWw3C|bFmsuGg(ODBg zqchf7eoeD?WHox+rq&bzdks8wm<(g&8B1ZMk3qX#Jj=W3V)irkc66`s#&p1Ri`Tg3 zl+oXPlAIsTi7ucsx< z-XmV?&dbMuFbqgN!{NfWo^JFC-j_i5i;p1UM!*W!${GJKknr_hgAdn90oRUh3CRkgwh)b4OnR+*;$Bw&V5B?H?6{HL5Lg z>!Tj2Kbg05T~}s(ozJ0S6Q2!dxf#14^k&H}2s36rif$i^3~w{uqmxzJ9wQJLdxhOuY_7g;I}c-YHgV57 zmR;5JmxJ~%W~aNE&$XEPW^CLmR&as(iQP5Zw-Z+8y>*~_~Dc4 zhb8lYiI_EYo=Gx?#%)AHYwdAmWwl z^$lGcH!Rp!p5Ad*u)2LNsn=>ps~ZMsZ80rAp?H&3ezo^Ocj`PV-Nsad10>s*A#ae? z)2SA*_sp_Lm1}}d>Ieo88kS_*6qjF!x{x0+<4ow{aJM%pSGB1ua$~Yxk7@rBkA;tP zN09{-U47!OaYQwE-gkP+)cCi-GOCQLPEs*?}g zUm$%cD0*_g^VrdZqbR*k@n=e@_GR8l9k%Po5Za^fR@y9$X(8Fe5hWy*@H-WIJqpByiQ?}zjKd}&-s6c*%3I1pA9 zC57*o0O0DTl7iewNP*%&0->0Weo=lNjS}+c=y@bRyq}Z-Efsn%mqD!M{%r2@NG_F! zcAH_~s-nRJQIG;esiH(;IZZ`J5Ao9AW6dxQjT(X|BI)S)et{^4LR4NYdh;wwrzzB?dnOFg; zFk-olhGLk*1Cn!PLa9P15u-GmAV(6Tprg^S9rZQ8D5;;{H+r%BiwdwFI29p3l{q)q(aF5F4j-EX)HtK{Fn&r{*Cu1^w-*lz%a_s zkLDra#%R)GdeG6D_%xn`E9B9J4oUE25XpEvh7Xd+7$VP+i{WrMJPZM%LOcqI%q4>0 z4^&LCTmg!?kcJ8-#|mK{k`vyMO5%bTGTs@&5J@}=2BbJrF;qO6;Kb!|C`5?!14V#L z2zMnY`Y|gF6%VH3QpkL~6OV)8kB8vjy z=@dqR0thD+3x;B71gFgmWYW=&Sp4^qKoO|m!wz(`uTUJL`fgwgqac<7)Tl`yJCVsm zJb_9kQV9eS@jEC4lF8vt)Nm5;SjSwUPz3w40Qc@T_^(3%!I?AL14z`F=&gZEy+h>yU}&z{5Wuc1OL$OM!SwUg zePn28f}9f4;%5jCbuc|?@%XETX2?ETgJY#SjesJhncd!X~o)hMyp4U^>#=BAVr-JE)`&U`g;`23SawQ DAJ=zF literal 0 HcmV?d00001 diff --git a/theme/hacker/icons/reaction.png b/theme/hacker/icons/reaction.png new file mode 100644 index 0000000000000000000000000000000000000000..1e27dcb245063b62c5947c04184117fa113d98c3 GIT binary patch literal 5118 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 LVSxwz6SMyUS>TNe literal 0 HcmV?d00001 diff --git a/theme/indymediamodern/icons/reaction.png b/theme/indymediamodern/icons/reaction.png new file mode 100644 index 0000000000000000000000000000000000000000..2908ae393b0127f4b7d7a89bc60aa1492986604b GIT binary patch literal 5116 zcmeHLc~leU7Eh#zf(WSa(29&fP~l86lRYs}1cFH+G!pQEQgt$!K!hw!1_)>sQ7G6- zDNq+geaZveMXM}Y7aky})VlS#R0W};<%rO_RmJkY1XMh)|9DROpGhW}x!=9N@7~}2 z<(^D(6%lis9Va+aC=_RzG$ayy23ilQ9eB^L=-5o54C_sgiXkJhRLE#DXtYTLM5Y-D zh_Gli6pH0~_c~qP4QNz$zj@81$yF$~Y4S4ZvDsg>{-Zz^uZb_)dt`;bT#?~fxz@Wr z{N9P>dA)WnM2G8-qB})g5!%#Z@70=Dv8TAIrt5Koc|@zrZ$@V%By(Qg#~*Tgr06^u^HXhS#rvSiU2*&>Hodt6&%*QLiU&;M|d-=s`YD_Zub-v^3ci;QTe|)3e!RpM2pSr3_qs}y(WKN4L4O;blNkDXsXDurZ z?{risJ5?pMXFNQnj+>BFP?l?_v0u2dh_NW)+V^MUd_TV(y5aN(#A^46n#W#uloGt9 zXzKO8HST@w8+RD-f=wI^b;Q~QlYVjR-7A_^xI5!t`;Zyjn5vhG0|A?BIS-{C33VZo zr4yYFruYZuEM9PXlxfz*b<(0SlfAFzxXy9dI!uxmTtCA#-=AkWJ$DK<@8O}foX|vi zfVb*KEM*%%bT~WKF-8)X)a?C5-b8=@3DKOLI}`s~Fs1R|(|yAp{ubcpA%{0^Tu{~C ze&NT$O-p<4`9ev%K1`ahrG;DJDF`PEudN$j5j1{b*Xm0NW9;g#<|dzS zeBe^+pr3qdMf)k93ETc+wDoQEjX8S8j>FFqmAsK(oEK%Yc5G_<{qe11mFu5vdh(dt z*PU6tedx14)a?)9&%GbA`?sB1dBC)SLZN19gM$^a;NVx<2Z}HG>}#>KKF~9}wS3L? zXond&eD#iqc_EUChaDw5T$g0c^9rZGT;=C1Tk}O}+k9hh{DH2v_C32-?S@ADkx^4C zn0`c>+v=O;f!_M`M*Y&(7pA9P(B+T9zrB8nDzE-tE%Ga8?N&7f9ML{ejiPRhJykNr zw{6Dp2X1XIJ}HWScIBlY{qmm1j0~yh?5InAx1cSz4mqED(SXg%k8U@&&n=`&`ntIvTMiYs%)PWqqsmEuyUt+DG#gcqp=mUwgc&zpm%j{Myqa z8PmtvU0*cuF7A2ns(n{<)3cc=CC^8?E*rk@QTd5DJIzele(vFBxAWD`adA8j;Zk#h zI(*BhYek<-mxeXp$#}|oUOwvl3MX=J+GmL+9gnEex%8Oni(|6=0YZlX)$sI6l^dN5Sz}XBe2AxO<~ezIzl3oS|f}M2_1w0S7KTMNg9O= zMrvv*J(Wc_nBp0zKpW8*0u;iF@RXis#5@9vx8cM z!6pJD4W=lAAxTWLrUY3dZSsah15J@?&A=ngs`x?8D~V4Bt_HpWNm|MVa$O}{e>m6L5YI5OB6AzgX>jO zMU4Mz`+&ocNe8GIuz?8*1~TLIL>}Y}H+d(1-_!M;u6JVKot)pR z>pflX#K1c_zgO2^MwjE8dpbf7e(0rwJGR2KZW7$>4OfImNn-6O-yJ=A;bihEV4;$c z5lTv6=T-2(6osk^n-fCGvGe57BV|(+7Ej#i1iJSt9@JUwDJXX*NuruD}N3sBi;7Kb~i2?mlr5o8Qv-W@{r`n z>R~HR)wh1zymeEj(+j~C?_=H_VLe$RBmoEf#!FmSi8|4glm70PN|Hy1E%ATeu&jUA zZAxYp$B^BqIPN_6hrx92zW{!_lRe*HK(#ii^7ZU3?AT;-)1G;ZjxpV5xr zE?nSAtGwn$OLggJ%r1-ya%o~QQYynC^v2n+p2)PP=krJSU=!|ba2fMZ8h-toZTDNo rQoZ)ME%)o`JaO_&=`;WSE6XWY>WC9h7J6R*MN(vvh>(3ji!=WR#Px^T literal 0 HcmV?d00001 diff --git a/theme/lcd/icons/reaction.png b/theme/lcd/icons/reaction.png new file mode 100644 index 0000000000000000000000000000000000000000..a59bc187f561432a90b8687fa3e7d0e8dc50ea59 GIT binary patch literal 5123 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 literal 0 HcmV?d00001 diff --git a/theme/night/icons/reaction.png b/theme/night/icons/reaction.png new file mode 100644 index 0000000000000000000000000000000000000000..0279ca5df6ffae3aaaf9ba9b4f8e92de918b8ade GIT binary patch literal 5541 zcmeHLeOya@8$VO&J?jM_nkDJZ-fCOhYKm1_df!Bd&bG5zt!-_q4I%PUi%LRBLb|G} z@df|@7&E;+g5kZpW*eae|F-Jx^aB7L`EvGrMKGvDNo5kgPz0eA%cKwrQVID0pt^H! zUTEx7veCz$KgzVttct8sdK9{0t8nhjBB;k!@;D%Weg+e%EyV$~4XZyb?#9I>2ID7p zr&DZ`1MXL{Uj;t<#6IUTp|4=CbozsLEoV!|=&>dp=y01udw6x3(!PyR%ZdwRzb|Ue z9@|e@bJ@?dw<_85wX2C|+_cxlWAE=S>4)krpR!-EF^_hPrY?2WQIF;=tbsKw6KX{Td5wZQz+6e_^kMdIP#bjD84Zi4o1Ch}%dT-L<6_FNp zi^M^1pKZheC$$!*hL(w!0ddW|v(AS4KOud8S+7 z8EIQxa)HE!cB%VE_Md%jU3F8d>JW&P9=~&XZt-Iiv3}~=5xtWw3C|bFmsuGg(ODBg zqchf7eoeD?WHox+rq&bzdks8wm<(g&8B1ZMk3qX#Jj=W3V)irkc66`s#&p1Ri`Tg3 zl+oXPlAIsTi7ucsx< z-XmV?&dbMuFbqgN!{NfWo^JFC-j_i5i;p1UM!*W!${GJKknr_hgAdn90oRUh3CRkgwh)b4OnR+*;$Bw&V5B?H?6{HL5Lg z>!Tj2Kbg05T~}s(ozJ0S6Q2!dxf#14^k&H}2s36rif$i^3~w{uqmxzJ9wQJLdxhOuY_7g;I}c-YHgV57 zmR;5JmxJ~%W~aNE&$XEPW^CLmR&as(iQP5Zw-Z+8y>*~_~Dc4 zhb8lYiI_EYo=Gx?#%)AHYwdAmWwl z^$lGcH!Rp!p5Ad*u)2LNsn=>ps~ZMsZ80rAp?H&3ezo^Ocj`PV-Nsad10>s*A#ae? z)2SA*_sp_Lm1}}d>Ieo88kS_*6qjF!x{x0+<4ow{aJM%pSGB1ua$~Yxk7@rBkA;tP zN09{-U47!OaYQwE-gkP+)cCi-GOCQLPEs*?}g zUm$%cD0*_g^VrdZqbR*k@n=e@_GR8l9k%Po5Za^fR@y9$X(8Fe5hWy*@H-WIJqpByiQ?}zjKd}&-s6c*%3I1pA9 zC57*o0O0DTl7iewNP*%&0->0Weo=lNjS}+c=y@bRyq}Z-Efsn%mqD!M{%r2@NG_F! zcAH_~s-nRJQIG;esiH(;IZZ`J5Ao9AW6dxQjT(X|BI)S)et{^4LR4NYdh;wwrzzB?dnOFg; zFk-olhGLk*1Cn!PLa9P15u-GmAV(6Tprg^S9rZQ8D5;;{H+r%BiwdwFI29p3l{q)q(aF5F4j-EX)HtK{Fn&r{*Cu1^w-*lz%a_s zkLDra#%R)GdeG6D_%xn`E9B9J4oUE25XpEvh7Xd+7$VP+i{WrMJPZM%LOcqI%q4>0 z4^&LCTmg!?kcJ8-#|mK{k`vyMO5%bTGTs@&5J@}=2BbJrF;qO6;Kb!|C`5?!14V#L z2zMnY`Y|gF6%VH3QpkL~6OV)8kB8vjy z=@dqR0thD+3x;B71gFgmWYW=&Sp4^qKoO|m!wz(`uTUJL`fgwgqac<7)Tl`yJCVsm zJb_9kQV9eS@jEC4lF8vt)Nm5;SjSwUPz3w40Qc@T_^(3%!I?AL14z`F=&gZEy+h>yU}&z{5Wuc1OL$OM!SwUg zePn28f}9f4;%5jCbuc|?@%XETX2?ETgJY#SjesJhncd!X~o)hMyp4U^>#=BAVr-JE)`&U`g;`23SawQ DAJ=zF literal 0 HcmV?d00001 diff --git a/theme/pixel/icons/reaction.png b/theme/pixel/icons/reaction.png new file mode 100644 index 0000000000000000000000000000000000000000..937e4de2ce41522d8b1d8fd758c3efd7aca12abf GIT binary patch literal 6588 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 J*MDy2{{Zu%ne+ev literal 0 HcmV?d00001 diff --git a/theme/rc3/icons/reaction.png b/theme/rc3/icons/reaction.png new file mode 100644 index 0000000000000000000000000000000000000000..5613bada9fd9fed447b629a922a9a8ca96710546 GIT binary patch literal 5122 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 R=@Mv?A`*rMmiotL`~}|!groof literal 0 HcmV?d00001 diff --git a/theme/solidaric/icons/reaction.png b/theme/solidaric/icons/reaction.png new file mode 100644 index 0000000000000000000000000000000000000000..a921e608d795f768811b493515d03247a88388e4 GIT binary patch literal 5118 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 str: + """Returns html for reaction icon/button + """ + reactionStr = '' + + if isModerationPost: + return reactionStr + + reactionIcon = 'reaction.png' + reactionLink = 'reactpick' + reactionTitle = 'Select reaction' + if translate.get(reactionTitle): + reactionTitle = translate[reactionTitle] + _logPostTiming(enableTimingLog, postStartTime, '12.65') + reactionPostId = removeIdEnding(postJsonObject['object']['id']) + reactionStr = \ + ' \n' + reactionStr += \ + ' ' + \ + '' + \
+        reactionEmoji + reactionTitle + ' |\n' + return reactionStr + + def _getMuteIconHtml(isMuted: bool, postActor: str, messageId: str, @@ -1247,7 +1283,8 @@ def _getPostTitleHtml(baseDir: str, def _getFooterWithIcons(showIcons: bool, containerClassIcons: str, replyStr: str, announceStr: str, - likeStr: str, bookmarkStr: str, + likeStr: str, reactionStr: str, + bookmarkStr: str, deleteStr: str, muteStr: str, editStr: str, postJsonObject: {}, publishedLink: str, timeClass: str, publishedStr: str) -> str: @@ -1258,7 +1295,7 @@ def _getFooterWithIcons(showIcons: bool, footerStr = '\n