diff --git a/daemon.py b/daemon.py index 05795bdcf..87909d1f3 100644 --- a/daemon.py +++ b/daemon.py @@ -192,6 +192,7 @@ from webapp_suspended import htmlSuspended from webapp_tos import htmlTermsOfService from webapp_confirm import htmlConfirmFollow from webapp_confirm import htmlConfirmUnfollow +from webapp_post import htmlEmojiReactionPicker from webapp_post import htmlPostReplies from webapp_post import htmlIndividualPost from webapp_post import individualPostAsHtml @@ -238,6 +239,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 @@ -654,12 +657,9 @@ class PubServer(BaseHTTPRequestHandler): return False return True - def _secureMode(self) -> bool: - """http authentication of GET requests for json + def _signedGETkeyId(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 +669,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 +680,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._signedGETkeyId() 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: @@ -1484,7 +1492,9 @@ class PubServer(BaseHTTPRequestHandler): originalMessageJson = messageJson.copy() # whether to add a 'to' field to the message - addToFieldTypes = ('Follow', 'Like', 'Add', 'Remove', 'Ignore') + addToFieldTypes = ( + 'Follow', 'Like', 'EmojiReact', 'Add', 'Remove', 'Ignore' + ) for addToType in addToFieldTypes: messageJson, toFieldExists = \ addToField(addToType, messageJson, self.server.debug) @@ -5701,6 +5711,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': @@ -7862,6 +7898,473 @@ 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 + Note that this is not the emoji reaction selection icon at the + bottom of the post + """ + 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 _reactionPicker(self, callingDomain: str, path: str, + baseDir: str, httpPrefix: str, + domain: str, domainFull: str, port: int, + onionDomain: str, i2pDomain: str, + GETstartTime, + proxyType: str, cookie: str, + debug: str) -> None: + """Press the emoji reaction picker icon at the bottom of the post + """ + pageNumber = 1 + reactionUrl = path.split('?selreact=')[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('?selreact=')[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] + 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 + + postJsonObject = None + reactionPostFilename = \ + locatePost(self.server.baseDir, + self.postToNickname, domain, reactionUrl) + if reactionPostFilename: + postJsonObject = loadJson(reactionPostFilename) + if not reactionPostFilename or not postJsonObject: + print('WARN: unable to locate 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) + return + + msg = \ + htmlEmojiReactionPicker(self.server.cssCache, + self.server.recentPostsCache, + self.server.maxRecentPosts, + self.server.translate, + self.server.baseDir, + self.server.session, + self.server.cachedWebfingers, + self.server.personCache, + self.postToNickname, + domain, port, postJsonObject, + self.server.httpPrefix, + self.server.projectVersion, + self.server.YTReplacementDomain, + self.server.twitterReplacementDomain, + self.server.showPublishedDateOnly, + self.server.peertubeInstances, + self.server.allowLocalNetworkAccess, + self.server.themeName, + self.server.systemLanguage, + self.server.maxLikeCount, + self.server.signingPrivateKeyPem, + self.server.CWlists, + self.server.listsEnabled, + self.server.defaultTimeline) + msg = msg.encode('utf-8') + msglen = len(msg) + self._set_headers('text/html', msglen, + cookie, callingDomain, False) + self._write(msg) + fitnessPerformance(GETstartTime, + self.server.fitness, + '_GET', '_reactionPicker', + self.server.debug) + self.server.GETbusy = False + def _bookmarkButton(self, callingDomain: str, path: str, baseDir: str, httpPrefix: str, domain: str, domainFull: str, port: int, @@ -8953,6 +9456,18 @@ class PubServer(BaseHTTPRequestHandler): likedBy = likedBy.split('?')[0] path = path.split('?likedBy=')[0] + reactBy = None + reactEmoji = None + if '?reactBy=' in path: + reactBy = path.split('?reactBy=')[1].strip() + if ';' in reactBy: + reactBy = reactBy.split(';')[0] + if ';emoj=' in path: + reactEmoji = path.split(';emoj=')[1].strip() + if ';' in reactEmoji: + reactEmoji = reactEmoji.split(';')[0] + path = path.split('?reactBy=')[0] + namedStatus = path.split('/@')[1] if '/' not in namedStatus: # show actor @@ -8977,6 +9492,7 @@ class PubServer(BaseHTTPRequestHandler): includeCreateWrapper = True result = self._showPostFromFile(postFilename, likedBy, + reactBy, reactEmoji, authorized, callingDomain, path, baseDir, httpPrefix, nickname, domain, domainFull, port, @@ -8990,6 +9506,7 @@ class PubServer(BaseHTTPRequestHandler): return result def _showPostFromFile(self, postFilename: str, likedBy: str, + reactBy: str, reactEmoji: str, authorized: bool, callingDomain: str, path: str, baseDir: str, httpPrefix: str, nickname: str, @@ -9036,7 +9553,7 @@ class PubServer(BaseHTTPRequestHandler): postJsonObject, httpPrefix, self.server.projectVersion, - likedBy, + likedBy, reactBy, reactEmoji, self.server.YTReplacementDomain, self.server.twitterReplacementDomain, self.server.showPublishedDateOnly, @@ -9099,6 +9616,19 @@ class PubServer(BaseHTTPRequestHandler): if '?' in likedBy: likedBy = likedBy.split('?')[0] path = path.split('?likedBy=')[0] + + reactBy = None + reactEmoji = None + if '?reactBy=' in path: + reactBy = path.split('?reactBy=')[1].strip() + if ';' in reactBy: + reactBy = reactBy.split(';')[0] + if ';emoj=' in path: + reactEmoji = path.split(';emoj=')[1].strip() + if ';' in reactEmoji: + reactEmoji = reactEmoji.split(';')[0] + path = path.split('?reactBy=')[0] + namedStatus = path.split('/users/')[1] if '/' not in namedStatus: return False @@ -9120,6 +9650,7 @@ class PubServer(BaseHTTPRequestHandler): includeCreateWrapper = True result = self._showPostFromFile(postFilename, likedBy, + reactBy, reactEmoji, authorized, callingDomain, path, baseDir, httpPrefix, nickname, domain, domainFull, port, @@ -9144,6 +9675,8 @@ class PubServer(BaseHTTPRequestHandler): and where you have the notify checkbox set on person options """ likedBy = None + reactBy = None + reactEmoji = None postId = path.split('?notifypost=')[1].strip() postId = postId.replace('-', '/') path = path.split('?notifypost=')[0] @@ -9161,6 +9694,7 @@ class PubServer(BaseHTTPRequestHandler): includeCreateWrapper = True result = self._showPostFromFile(postFilename, likedBy, + reactBy, reactEmoji, authorized, callingDomain, path, baseDir, httpPrefix, nickname, domain, domainFull, port, @@ -12980,6 +13514,7 @@ class PubServer(BaseHTTPRequestHandler): nickname = self.path.split('/users/')[1] if '/' in nickname: nickname = nickname.split('/')[0] + # return the featured posts collection self._getFeaturedCollection(callingDomain, self.server.baseDir, self.path, @@ -14346,7 +14881,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 @@ -14364,7 +14899,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 @@ -14386,6 +14958,25 @@ class PubServer(BaseHTTPRequestHandler): '_GET', 'bookmark shown done', self.server.debug) + # emoji recation from the web interface bottom icon + if authorized and htmlGET and '?selreact=' in self.path: + self._reactionPicker(callingDomain, self.path, + self.server.baseDir, + self.server.httpPrefix, + self.server.domain, + self.server.domainFull, + self.server.port, + self.server.onionDomain, + self.server.i2pDomain, + GETstartTime, + self.server.proxyType, + cookie, self.server.debug) + return + + fitnessPerformance(GETstartTime, self.server.fitness, + '_GET', 'bookmark shown done', + self.server.debug) + # undo a bookmark from the web interface icon if authorized and htmlGET and '?unbookmark=' in self.path: self._undoBookmarkButton(callingDomain, self.path, @@ -15495,8 +16086,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, diff --git a/emoji/default_reactions.json b/emoji/default_reactions.json new file mode 100644 index 000000000..758cdeb44 --- /dev/null +++ b/emoji/default_reactions.json @@ -0,0 +1,1293 @@ +{ + "SMILEYS_AND_EMOTION": [ + "๐", + "๐", + "๐", + "๐", + "๐", + "๐ ", + "๐คฃ", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐ฅฐ", + "๐", + "๐คฉ", + "๐", + "๐", + "โบ", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐คช", + "๐", + "๐ค", + "๐ค", + "๐คญ", + "๐คซ", + "๐ค", + "๐ค", + "๐คจ", + "๐", + "๐", + "๐ถ", + "๐", + "๐", + "๐", + "๐ฌ", + "๐คฅ", + "๐", + "๐", + "๐ช", + "๐คค", + "๐ด", + "๐ท", + "๐ค", + "๐ค", + "๐คข", + "๐คฎ", + "๐คง", + "๐ฅต", + "๐ฅถ", + "๐ฅด", + "๐ต", + "๐คฏ", + "๐ค ", + "๐ฅณ", + "๐", + "๐ค", + "๐ง", + "๐", + "๐", + "๐", + "โน", + "๐ฎ", + "๐ฏ", + "๐ฒ", + "๐ณ", + "๐ฅบ", + "๐ฆ", + "๐ง", + "๐จ", + "๐ฐ", + "๐ฅ", + "๐ข", + "๐ญ", + "๐ฑ", + "๐", + "๐ฃ", + "๐", + "๐", + "๐ฉ", + "๐ซ", + "๐ฅฑ", + "๐ค", + "๐ก", + "๐ ", + "๐คฌ", + "๐", + "๐ฟ", + "๐", + "โ ", + "๐ฉ", + "๐คก", + "๐น", + "๐บ", + "๐ป", + "๐ฝ", + "๐พ", + "๐ค", + "๐บ", + "๐ธ", + "๐น", + "๐ป", + "๐ผ", + "๐ฝ", + "๐", + "๐ฟ", + "๐พ", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "โฃ", + "๐", + "โค", + "๐งก", + "๐", + "๐", + "๐", + "๐", + "๐ค", + "๐ค", + "๐ค", + "๐ฏ", + "๐ข", + "๐ฅ", + "๐ซ", + "๐ฆ", + "๐จ", + "๐ณ", + "๐ฃ", + "๐ฌ", + "๐จ", + "๐ฏ", + "๐ญ", + "๐ค" + ], + "PEOPLE_AND_BODY": [ + "๐", + "๐ค", + "๐", + "โ", + "๐", + "๐", + "๐ค", + "โ", + "๐ค", + "๐ค", + "๐ค", + "๐ค", + "๐", + "๐", + "๐", + "๐", + "๐", + "โ", + "๐", + "๐", + "โ", + "๐", + "๐ค", + "๐ค", + "๐", + "๐", + "๐", + "๐คฒ", + "๐ค", + "๐", + "โ", + "๐ ", + "๐คณ", + "๐ช", + "๐ฆพ", + "๐ฆฟ", + "๐ฆต", + "๐ฆถ", + "๐", + "๐ฆป", + "๐", + "๐ง ", + "๐ฆท", + "๐ฆด", + "๐", + "๐", + "๐ ", + "๐", + "๐ถ", + "๐ง", + "๐ฆ", + "๐ง", + "๐ง", + "๐ฑ", + "๐จ", + "๐ง", + "๐ฉ", + "๐ง", + "๐ด", + "๐ต", + "๐", + "๐", + "๐ ", + "๐", + "๐", + "๐", + "๐ง", + "๐", + "๐คฆ", + "๐คท", + "๐ฎ", + "๐ต", + "๐", + "๐ท", + "๐คด", + "๐ธ", + "๐ณ", + "๐ฒ", + "๐ง", + "๐คต", + "๐ฐ", + "๐คฐ", + "๐คฑ", + "๐ผ", + "๐ ", + "๐คถ", + "๐ฆธ", + "๐ฆน", + "๐ง", + "๐ง", + "๐ง", + "๐ง", + "๐ง", + "๐ง", + "๐ง", + "๐", + "๐", + "๐ถ", + "๐ง", + "๐ง", + "๐", + "๐", + "๐บ", + "๐ด", + "๐ฏ", + "๐ง", + "๐ง", + "๐คบ", + "๐", + "โท", + "๐", + "๐", + "๐", + "๐ฃ", + "๐", + "โน", + "๐", + "๐ด", + "๐ต", + "๐คธ", + "๐คผ", + "๐คฝ", + "๐คพ", + "๐คน", + "๐ง", + "๐", + "๐", + "๐ญ", + "๐ซ", + "๐ฌ", + "๐", + "๐", + "๐ช", + "๐ฃ", + "๐ค", + "๐ฅ", + "๐ฃ" + ], + "COMPONENT": [ + "๐ป", + "๐ผ", + "๐ฝ", + "๐พ", + "๐ฟ", + "๐ฆฐ", + "๐ฆฑ", + "๐ฆณ", + "๐ฆฒ" + ], + "ANIMALS_AND_NATURE": [ + "๐ต", + "๐", + "๐ฆ", + "๐ฆง", + "๐ถ", + "๐", + "๐ฆฎ", + "๐ฉ", + "๐บ", + "๐ฆ", + "๐ฆ", + "๐ฑ", + "๐", + "๐ฆ", + "๐ฏ", + "๐ ", + "๐", + "๐ด", + "๐", + "๐ฆ", + "๐ฆ", + "๐ฆ", + "๐ฎ", + "๐", + "๐", + "๐", + "๐ท", + "๐", + "๐", + "๐ฝ", + "๐", + "๐", + "๐", + "๐ช", + "๐ซ", + "๐ฆ", + "๐ฆ", + "๐", + "๐ฆ", + "๐ฆ", + "๐ญ", + "๐", + "๐", + "๐น", + "๐ฐ", + "๐", + "๐ฟ", + "๐ฆ", + "๐ฆ", + "๐ป", + "๐จ", + "๐ผ", + "๐ฆฅ", + "๐ฆฆ", + "๐ฆจ", + "๐ฆ", + "๐ฆก", + "๐พ", + "๐ฆ", + "๐", + "๐", + "๐ฃ", + "๐ค", + "๐ฅ", + "๐ฆ", + "๐ง", + "๐", + "๐ฆ ", + "๐ฆ", + "๐ฆข", + "๐ฆ", + "๐ฆฉ", + "๐ฆ", + "๐ฆ", + "๐ธ", + "๐", + "๐ข", + "๐ฆ", + "๐", + "๐ฒ", + "๐", + "๐ฆ", + "๐ฆ", + "๐ณ", + "๐", + "๐ฌ", + "๐", + "๐ ", + "๐ก", + "๐ฆ", + "๐", + "๐", + "๐", + "๐ฆ", + "๐", + "๐", + "๐", + "๐", + "๐ฆ", + "๐ท", + "๐ธ", + "๐ฆ", + "๐ฆ", + "๐ฆ ", + "๐", + "๐ธ", + "๐ฎ", + "๐ต", + "๐น", + "๐ฅ", + "๐บ", + "๐ป", + "๐ผ", + "๐ท", + "๐ฑ", + "๐ฒ", + "๐ณ", + "๐ด", + "๐ต", + "๐พ", + "๐ฟ", + "โ", + "๐", + "๐", + "๐", + "๐" + ], + "FOOD_AND_DRINK": [ + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐ฅญ", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐ฅ", + "๐ ", + "๐ฅฅ", + "๐ฅ", + "๐", + "๐ฅ", + "๐ฅ", + "๐ฝ", + "๐ถ", + "๐ฅ", + "๐ฅฌ", + "๐ฅฆ", + "๐ง", + "๐ง ", + "๐", + "๐ฅ", + "๐ฐ", + "๐", + "๐ฅ", + "๐ฅ", + "๐ฅจ", + "๐ฅฏ", + "๐ฅ", + "๐ง", + "๐ง", + "๐", + "๐", + "๐ฅฉ", + "๐ฅ", + "๐", + "๐", + "๐", + "๐ญ", + "๐ฅช", + "๐ฎ", + "๐ฏ", + "๐ฅ", + "๐ง", + "๐ฅ", + "๐ณ", + "๐ฅ", + "๐ฒ", + "๐ฅฃ", + "๐ฅ", + "๐ฟ", + "๐ง", + "๐ง", + "๐ฅซ", + "๐ฑ", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐ ", + "๐ข", + "๐ฃ", + "๐ค", + "๐ฅ", + "๐ฅฎ", + "๐ก", + "๐ฅ", + "๐ฅ ", + "๐ฅก", + "๐ฆ", + "๐ฆ", + "๐ฆ", + "๐ฆ", + "๐ฆช", + "๐ฆ", + "๐ง", + "๐จ", + "๐ฉ", + "๐ช", + "๐", + "๐ฐ", + "๐ง", + "๐ฅง", + "๐ซ", + "๐ฌ", + "๐ญ", + "๐ฎ", + "๐ฏ", + "๐ผ", + "๐ฅ", + "โ", + "๐ต", + "๐ถ", + "๐พ", + "๐ท", + "๐ธ", + "๐น", + "๐บ", + "๐ป", + "๐ฅ", + "๐ฅ", + "๐ฅค", + "๐ง", + "๐ง", + "๐ง", + "๐ฅข", + "๐ฝ", + "๐ด", + "๐ฅ", + "๐ช", + "๐บ" + ], + "TRAVEL_AND_PLACES": [ + "๐", + "๐", + "๐", + "๐", + "๐บ", + "๐พ", + "๐งญ", + "๐", + "โฐ", + "๐", + "๐ป", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐งฑ", + "๐", + "๐", + "๐ ", + "๐ก", + "๐ข", + "๐ฃ", + "๐ค", + "๐ฅ", + "๐ฆ", + "๐จ", + "๐ฉ", + "๐ช", + "๐ซ", + "๐ฌ", + "๐ญ", + "๐ฏ", + "๐ฐ", + "๐", + "๐ผ", + "๐ฝ", + "โช", + "๐", + "๐", + "๐", + "โฉ", + "๐", + "โฒ", + "โบ", + "๐", + "๐", + "๐", + "๐", + "๐ ", + "๐", + "๐", + "๐", + "โจ", + "๐ ", + "๐ก", + "๐ข", + "๐", + "๐ช", + "๐", + "๐", + "๐", + "๐ ", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐ต", + "๐ฆฝ", + "๐ฆผ", + "๐บ", + "๐ฒ", + "๐ด", + "๐น", + "๐", + "๐ฃ", + "๐ค", + "๐ข", + "โฝ", + "๐จ", + "๐ฅ", + "๐ฆ", + "๐", + "๐ง", + "โ", + "โต", + "๐ถ", + "๐ค", + "๐ณ", + "โด", + "๐ฅ", + "๐ข", + "โ", + "๐ฉ", + "๐ซ", + "๐ฌ", + "๐ช", + "๐บ", + "๐", + "๐", + "๐ ", + "๐ก", + "๐ฐ", + "๐", + "๐ธ", + "๐", + "๐งณ", + "โ", + "โณ", + "โ", + "โฐ", + "โฑ", + "โฒ", + "๐ฐ", + "๐", + "๐ง", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐ ", + "๐", + "๐ก", + "๐", + "๐ข", + "๐", + "๐ฃ", + "๐", + "๐ค", + "๐", + "๐ฅ", + "๐", + "๐ฆ", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐ก", + "โ", + "๐", + "๐", + "๐ช", + "โญ", + "๐", + "๐ ", + "๐", + "โ", + "โ ", + "โ", + "๐ค", + "๐ฅ", + "๐ฆ", + "๐ง", + "๐จ", + "๐ฉ", + "๐ช", + "๐ซ", + "๐ฌ", + "๐", + "๐", + "๐", + "โ", + "โ", + "โฑ", + "โก", + "โ", + "โ", + "โ", + "โ", + "๐ฅ", + "๐ง", + "๐" + ], + "ACTIVITIES": [ + "๐", + "๐", + "๐", + "๐", + "๐งจ", + "โจ", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐งง", + "๐", + "๐", + "๐", + "๐", + "๐ซ", + "๐", + "๐", + "๐ ", + "๐ฅ", + "๐ฅ", + "๐ฅ", + "โฝ", + "โพ", + "๐ฅ", + "๐", + "๐", + "๐", + "๐", + "๐พ", + "๐ฅ", + "๐ณ", + "๐", + "๐", + "๐", + "๐ฅ", + "๐", + "๐ธ", + "๐ฅ", + "๐ฅ", + "๐ฅ ", + "โณ", + "โธ", + "๐ฃ", + "๐คฟ", + "๐ฝ", + "๐ฟ", + "๐ท", + "๐ฅ", + "๐ฏ", + "๐ช", + "๐ช", + "๐ฑ", + "๐ฎ", + "๐งฟ", + "๐ฎ", + "๐น", + "๐ฐ", + "๐ฒ", + "๐งฉ", + "๐งธ", + "โ ", + "โฅ", + "โฆ", + "โฃ", + "โ", + "๐", + "๐", + "๐ด", + "๐ญ", + "๐ผ", + "๐จ", + "๐งต", + "๐งถ" + ], + "OBJECTS": [ + "๐", + "๐ถ", + "๐ฅฝ", + "๐ฅผ", + "๐ฆบ", + "๐", + "๐", + "๐", + "๐งฃ", + "๐งค", + "๐งฅ", + "๐งฆ", + "๐", + "๐", + "๐ฅป", + "๐ฉฑ", + "๐ฉฒ", + "๐ฉณ", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐ฅพ", + "๐ฅฟ", + "๐ ", + "๐", + "๐ฉฐ", + "๐", + "๐ฉ", + "๐", + "๐งข", + "โ", + "๐ฟ", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐ข", + "๐ฃ", + "๐ฏ", + "๐", + "๐", + "๐ผ", + "๐ต", + "๐ถ", + "๐", + "๐", + "๐", + "๐ค", + "๐ง", + "๐ป", + "๐ท", + "๐ธ", + "๐น", + "๐บ", + "๐ป", + "๐ช", + "๐ฅ", + "๐ฑ", + "๐ฒ", + "โ", + "๐", + "๐", + "๐ ", + "๐", + "๐", + "๐ป", + "๐ฅ", + "๐จ", + "โจ", + "๐ฑ", + "๐ฒ", + "๐ฝ", + "๐พ", + "๐ฟ", + "๐", + "๐งฎ", + "๐ฅ", + "๐", + "๐ฝ", + "๐ฌ", + "๐บ", + "๐ท", + "๐ธ", + "๐น", + "๐ผ", + "๐", + "๐", + "๐ฏ", + "๐ก", + "๐ฆ", + "๐ฎ", + "๐ช", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐ฐ", + "๐", + "๐", + "๐", + "๐ท", + "๐ฐ", + "๐ด", + "๐ต", + "๐ถ", + "๐ท", + "๐ธ", + "๐ณ", + "๐งพ", + "๐น", + "๐ฑ", + "๐ฒ", + "โ", + "๐ง", + "๐จ", + "๐ฉ", + "๐ค", + "๐ฅ", + "๐ฆ", + "๐ซ", + "๐ช", + "๐ฌ", + "๐ญ", + "๐ฎ", + "๐ณ", + "โ", + "โ", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐ผ", + "๐", + "๐", + "๐", + "๐ ", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "โ", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐จ", + "๐ช", + "โ", + "โ", + "๐ ", + "๐ก", + "โ", + "๐ซ", + "๐น", + "๐ก", + "๐ง", + "๐ฉ", + "โ", + "๐", + "โ", + "๐ฆฏ", + "๐", + "โ", + "๐งฐ", + "๐งฒ", + "โ", + "๐งช", + "๐งซ", + "๐งฌ", + "๐ฌ", + "๐ญ", + "๐ก", + "๐", + "๐ฉธ", + "๐", + "๐ฉน", + "๐ฉบ", + "๐ช", + "๐", + "๐", + "๐ช", + "๐ฝ", + "๐ฟ", + "๐", + "๐ช", + "๐งด", + "๐งท", + "๐งน", + "๐งบ", + "๐งป", + "๐งผ", + "๐งฝ", + "๐งฏ", + "๐", + "๐ฌ", + "โฐ", + "โฑ", + "๐ฟ" + ], + "SYMBOLS": [ + "๐ง", + "๐ฎ", + "๐ฐ", + "โฟ", + "๐น", + "๐บ", + "๐ป", + "๐ผ", + "๐พ", + "๐", + "๐", + "๐", + "๐ ", + "โ ", + "๐ธ", + "โ", + "๐ซ", + "๐ณ", + "๐ญ", + "๐ฏ", + "๐ฑ", + "๐ท", + "๐ต", + "๐", + "โข", + "โฃ", + "โฌ", + "โ", + "โก", + "โ", + "โฌ", + "โ", + "โฌ ", + "โ", + "โ", + "โ", + "โฉ", + "โช", + "โคด", + "โคต", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐", + "โ", + "๐", + "โก", + "โธ", + "โฏ", + "โ", + "โฆ", + "โช", + "โฎ", + "๐", + "๐ฏ", + "โ", + "โ", + "โ", + "โ", + "โ", + "โ", + "โ", + "โ", + "โ", + "โ", + "โ", + "โ", + "โ", + "๐", + "๐", + "๐", + "โถ", + "โฉ", + "โญ", + "โฏ", + "โ", + "โช", + "โฎ", + "๐ผ", + "โซ", + "๐ฝ", + "โฌ", + "โธ", + "โน", + "โบ", + "โ", + "๐ฆ", + "๐ ", + "๐", + "๐ถ", + "๐ณ", + "๐ด", + "โ", + "โ", + "โ", + "โพ", + "โป", + "โ", + "๐ฑ", + "๐", + "๐ฐ", + "โญ", + "โ ", + "โ", + "โ", + "โ", + "โ", + "โ", + "โ", + "โ", + "โ", + "โฐ", + "โฟ", + "ใฝ", + "โณ", + "โด", + "โ", + "โผ", + "โ", + "โ", + "โ", + "โ", + "โ", + "ใฐ", + "ยฉ", + "ยฎ", + "โข", + "๐", + "๐ ", + "๐ก", + "๐ข", + "๐ฃ", + "๐ค", + "๐ ฐ", + "๐", + "๐ ฑ", + "๐", + "๐", + "๐", + "โน", + "๐", + "โ", + "๐", + "๐", + "๐ พ", + "๐", + "๐ ฟ", + "๐", + "๐", + "๐", + "๐", + "๐", + "๐ท", + "๐ถ", + "๐ฏ", + "๐", + "๐น", + "๐", + "๐ฒ", + "๐", + "๐ธ", + "๐ด", + "๐ณ", + "ใ", + "ใ", + "๐บ", + "๐ต", + "๐ด", + "๐ ", + "๐ก", + "๐ข", + "๐ต", + "๐ฃ", + "๐ค", + "โซ", + "โช", + "๐ฅ", + "๐ง", + "๐จ", + "๐ฉ", + "๐ฆ", + "๐ช", + "๐ซ", + "โฌ", + "โฌ", + "โผ", + "โป", + "โพ", + "โฝ", + "โช", + "โซ", + "๐ถ", + "๐ท", + "๐ธ", + "๐น", + "๐บ", + "๐ป", + "๐ ", + "๐", + "๐ณ", + "๐ฒ" + ], + "FLAGS": [ + "๐", + "๐ฉ", + "๐", + "๐ด", + "๐ณ" + ] +} diff --git a/epicyon-profile.css b/epicyon-profile.css index 45b41ac4d..b6a6add5e 100644 --- a/epicyon-profile.css +++ b/epicyon-profile.css @@ -51,6 +51,10 @@ --font-size-pgp-key2: 18px; --font-size-tox: 16px; --font-size-tox2: 18px; + --font-size-emoji-reaction: 16px; + --font-size-emoji-reaction-mobile: 24px; + --follow-text-size1: 24px; + --follow-text-size2: 40px; --time-color: #aaa; --time-vertical-align: 0%; --time-vertical-align-mobile: 1.5%; @@ -1037,6 +1041,10 @@ div.container { font-size: var(--font-size); color: var(--title-color); } + .followText { + font-size: var(--follow-text-size1); + font-family: Arial, Helvetica, sans-serif; + } .accountsTable { width: 100%; border: 0; @@ -1731,14 +1739,39 @@ div.container { .pageslist { } .voteresult { - width: var(--voteresult-width); - height: var(--voteresult-height); + width: var(--voteresult-width); + height: var(--voteresult-height); } .voteresultbar { - height: var(--voteresult-height); - fill: var(--voteresult-color); - stroke-width: 10; - stroke: var(--voteresult-border-color) + height: var(--voteresult-height); + fill: var(--voteresult-color); + stroke-width: 10; + stroke: var(--voteresult-border-color) + } + .emojiReactionBar { + } + .emojiReactionButton { + display: -webkit-inline-box; + display: -ms-inline-flexbox; + display: inline-flex; + line-height: 2rem; + margin-right: 6px; + padding: 1px 6px; + border: 1px solid var(--border-color); + border-radius: 10px; + background-color: var(--main-bg-color); + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + vertical-align: middle; + font-size: var(--font-size-emoji-reaction); + } + .rlab { + font-size: var(--font-size); + margin: 15px 15px; + line-height: 200%; } } @@ -2446,13 +2479,42 @@ div.container { .pageslist { } .voteresult { - width: var(--voteresult-width-mobile); - height: var(--voteresult-height-mobile); + width: var(--voteresult-width-mobile); + height: var(--voteresult-height-mobile); } .voteresultbar { - height: var(--voteresult-height-mobile); - fill: var(--voteresult-color); - stroke-width: 10; - stroke: var(--voteresult-border-color) + height: var(--voteresult-height-mobile); + fill: var(--voteresult-color); + stroke-width: 10; + stroke: var(--voteresult-border-color) + } + .emojiReactionBar { + } + .emojiReactionButton { + display: -webkit-inline-box; + display: -ms-inline-flexbox; + display: inline-flex; + line-height: 2rem; + margin-right: 6px; + padding: 1px 6px; + border: 1px solid var(--border-color); + border-radius: 10px; + background-color: var(--main-bg-color); + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + vertical-align: middle; + font-size: var(--font-size-emoji-reaction-mobile); + } + .followText { + font-size: var(--follow-text-size2); + font-family: Arial, Helvetica, sans-serif; + } + .rlab { + font-size: var(--font-size-mobile); + margin: 25px 25px; + line-height: 300%; } } diff --git a/epicyon.py b/epicyon.py index 9cf52179e..7cc492481 100644 --- a/epicyon.py +++ b/epicyon.py @@ -81,6 +81,9 @@ from media import getAttachmentMediaType from delete import sendDeleteViaServer from like import sendLikeViaServer from like import sendUndoLikeViaServer +from reaction import sendReactionViaServer +from reaction import sendUndoReactionViaServer +from reaction import validEmojiContent from skills import sendSkillViaServer from availability import setAvailability from availability import sendAvailabilityViaServer @@ -510,6 +513,13 @@ parser.add_argument('--favorite', '--like', dest='like', type=str, default=None, help='Like a url') parser.add_argument('--undolike', '--unlike', dest='undolike', type=str, default=None, help='Undo a like of a url') +parser.add_argument('--react', '--reaction', dest='react', type=str, + default=None, help='Reaction url') +parser.add_argument('--emoji', type=str, + default=None, help='Reaction emoji') +parser.add_argument('--undoreact', '--undoreaction', dest='undoreact', + type=str, + default=None, help='Reaction url') parser.add_argument('--bookmark', '--bm', dest='bookmark', type=str, default=None, help='Bookmark the url of a post') @@ -1612,6 +1622,45 @@ if args.like: time.sleep(1) sys.exit() +if args.react: + if not args.nickname: + print('Specify a nickname with the --nickname option') + sys.exit() + if not args.emoji: + print('Specify a reaction emoji with the --emoji option') + sys.exit() + if not validEmojiContent(args.emoji): + print('This is not a valid emoji') + sys.exit() + + if not args.password: + args.password = getpass.getpass('Password: ') + if not args.password: + print('Specify a password with the --password option') + sys.exit() + args.password = args.password.replace('\n', '') + + session = createSession(proxyType) + personCache = {} + cachedWebfingers = {} + if not domain: + domain = getConfigParam(baseDir, 'domain') + signingPrivateKeyPem = None + if args.secureMode: + signingPrivateKeyPem = getInstanceActorKey(baseDir, domain) + print('Sending emoji reaction ' + args.emoji + ' to ' + args.react) + + sendReactionViaServer(baseDir, session, + args.nickname, args.password, + domain, port, + httpPrefix, args.react, args.emoji, + cachedWebfingers, personCache, + True, __version__, signingPrivateKeyPem) + for i in range(10): + # TODO detect send success/fail + time.sleep(1) + sys.exit() + if args.undolike: if not args.nickname: print('Specify a nickname with the --nickname option') @@ -1646,6 +1695,46 @@ if args.undolike: time.sleep(1) sys.exit() +if args.undoreact: + if not args.nickname: + print('Specify a nickname with the --nickname option') + sys.exit() + if not args.emoji: + print('Specify a reaction emoji with the --emoji option') + sys.exit() + if not validEmojiContent(args.emoji): + print('This is not a valid emoji') + sys.exit() + + if not args.password: + args.password = getpass.getpass('Password: ') + if not args.password: + print('Specify a password with the --password option') + sys.exit() + args.password = args.password.replace('\n', '') + + session = createSession(proxyType) + personCache = {} + cachedWebfingers = {} + if not domain: + domain = getConfigParam(baseDir, 'domain') + signingPrivateKeyPem = None + if args.secureMode: + signingPrivateKeyPem = getInstanceActorKey(baseDir, domain) + print('Sending undo emoji reaction ' + args.emoji + ' to ' + args.react) + + sendUndoReactionViaServer(baseDir, session, + args.nickname, args.password, + domain, port, + httpPrefix, args.undoreact, args.emoji, + cachedWebfingers, personCache, + True, __version__, + signingPrivateKeyPem) + for i in range(10): + # TODO detect send success/fail + time.sleep(1) + sys.exit() + if args.bookmark: if not args.nickname: print('Specify a nickname with the --nickname option') diff --git a/inbox.py b/inbox.py index fab4fca6e..a2bbdf81d 100644 --- a/inbox.py +++ b/inbox.py @@ -15,6 +15,9 @@ import random from linked_data_sig import verifyJsonSignature from languages import understoodPostLanguage from like import updateLikesCollection +from reaction import updateReactionCollection +from reaction import validEmojiContent +from utils import removeHtml from utils import fileLastModified from utils import hasObjectString from utils import hasObjectStringObject @@ -50,6 +53,7 @@ from utils import removeModerationPostFromIndex from utils import loadJson from utils import saveJson from utils import undoLikesCollectionEntry +from utils import undoReactionCollectionEntry from utils import hasGroupType from utils import localActorUrl from utils import hasObjectStringType @@ -393,7 +397,8 @@ def inboxMessageHasParams(messageJson: {}) -> bool: return False if not messageJson.get('to'): - allowedWithoutToParam = ['Like', 'Follow', 'Join', 'Request', + allowedWithoutToParam = ['Like', 'EmojiReact', + 'Follow', 'Join', 'Request', 'Accept', 'Capability', 'Undo'] if messageJson['type'] not in allowedWithoutToParam: return False @@ -415,7 +420,9 @@ def inboxPermittedMessage(domain: str, messageJson: {}, if not urlPermitted(actor, federationList): return False - alwaysAllowedTypes = ('Follow', 'Join', 'Like', 'Delete', 'Announce') + alwaysAllowedTypes = ( + 'Follow', 'Join', 'Like', 'EmojiReact', 'Delete', 'Announce' + ) if messageJson['type'] not in alwaysAllowedTypes: if not hasObjectDict(messageJson): return True @@ -1203,6 +1210,275 @@ def _receiveUndoLike(recentPostsCache: {}, return True +def _receiveReaction(recentPostsCache: {}, + session, handle: str, isGroup: bool, baseDir: str, + httpPrefix: str, domain: str, port: int, + onionDomain: str, + sendThreads: [], postLog: [], cachedWebfingers: {}, + personCache: {}, messageJson: {}, federationList: [], + debug: bool, + signingPrivateKeyPem: str, + maxRecentPosts: int, translate: {}, + allowDeletion: bool, + YTReplacementDomain: str, + twitterReplacementDomain: str, + peertubeInstances: [], + allowLocalNetworkAccess: bool, + themeName: str, systemLanguage: str, + maxLikeCount: int, CWlists: {}, + listsEnabled: str) -> bool: + """Receives an emoji reaction within the POST section of HTTPServer + """ + if messageJson['type'] != 'EmojiReact': + return False + if not hasActor(messageJson, debug): + return False + if not hasObjectString(messageJson, debug): + return False + if not messageJson.get('to'): + if debug: + print('DEBUG: ' + messageJson['type'] + ' has no "to" list') + return False + if not messageJson.get('content'): + if debug: + print('DEBUG: ' + messageJson['type'] + ' has no "content"') + return False + if not isinstance(messageJson['content'], str): + if debug: + print('DEBUG: ' + messageJson['type'] + ' content is not string') + return False + if not validEmojiContent(messageJson['content']): + print('_receiveReaction: Invalid emoji reaction: "' + + messageJson['content'] + '" from ' + messageJson['actor']) + return False + if not hasUsersPath(messageJson['actor']): + if debug: + print('DEBUG: "users" or "profile" missing from actor in ' + + messageJson['type']) + return False + if '/statuses/' not in messageJson['object']: + if debug: + print('DEBUG: "statuses" missing from object in ' + + messageJson['type']) + return False + if not os.path.isdir(baseDir + '/accounts/' + handle): + print('DEBUG: unknown recipient of emoji reaction - ' + handle) + # if this post in the outbox of the person? + handleName = handle.split('@')[0] + handleDom = handle.split('@')[1] + postReactionId = messageJson['object'] + emojiContent = removeHtml(messageJson['content']) + if not emojiContent: + if debug: + print('DEBUG: emoji reaction has no content') + return True + postFilename = locatePost(baseDir, handleName, handleDom, postReactionId) + if not postFilename: + if debug: + print('DEBUG: emoji reaction post not found in inbox or outbox') + print(postReactionId) + return True + if debug: + print('DEBUG: emoji reaction post found in inbox') + + reactionActor = messageJson['actor'] + handleName = handle.split('@')[0] + handleDom = handle.split('@')[1] + if not _alreadyReacted(baseDir, + handleName, handleDom, + postReactionId, + reactionActor, + emojiContent): + _reactionNotify(baseDir, domain, onionDomain, handle, + reactionActor, postReactionId, emojiContent) + updateReactionCollection(recentPostsCache, baseDir, postFilename, + postReactionId, reactionActor, + handleName, domain, debug, None, emojiContent) + # regenerate the html + reactionPostJson = loadJson(postFilename, 0, 1) + if reactionPostJson: + if reactionPostJson.get('type'): + if reactionPostJson['type'] == 'Announce' and \ + reactionPostJson.get('object'): + if isinstance(reactionPostJson['object'], str): + announceReactionUrl = reactionPostJson['object'] + announceReactionFilename = \ + locatePost(baseDir, handleName, + domain, announceReactionUrl) + if announceReactionFilename: + postReactionId = announceReactionUrl + postFilename = announceReactionFilename + updateReactionCollection(recentPostsCache, + baseDir, + postFilename, + postReactionId, + reactionActor, + handleName, + domain, debug, None, + emojiContent) + if reactionPostJson: + if debug: + cachedPostFilename = \ + getCachedPostFilename(baseDir, handleName, domain, + reactionPostJson) + print('Reaction post json: ' + str(reactionPostJson)) + print('Reaction post nickname: ' + handleName + ' ' + domain) + print('Reaction post cache: ' + str(cachedPostFilename)) + pageNumber = 1 + showPublishedDateOnly = False + showIndividualPostIcons = True + manuallyApproveFollowers = \ + followerApprovalActive(baseDir, handleName, domain) + notDM = not isDM(reactionPostJson) + individualPostAsHtml(signingPrivateKeyPem, False, + recentPostsCache, maxRecentPosts, + translate, pageNumber, baseDir, + session, cachedWebfingers, personCache, + handleName, domain, port, reactionPostJson, + None, True, allowDeletion, + httpPrefix, __version__, + 'inbox', + YTReplacementDomain, + twitterReplacementDomain, + showPublishedDateOnly, + peertubeInstances, + allowLocalNetworkAccess, + themeName, systemLanguage, + maxLikeCount, notDM, + showIndividualPostIcons, + manuallyApproveFollowers, + False, True, False, CWlists, + listsEnabled) + return True + + +def _receiveUndoReaction(recentPostsCache: {}, + session, handle: str, isGroup: bool, baseDir: str, + httpPrefix: str, domain: str, port: int, + sendThreads: [], postLog: [], cachedWebfingers: {}, + personCache: {}, messageJson: {}, federationList: [], + debug: bool, + signingPrivateKeyPem: str, + maxRecentPosts: int, translate: {}, + allowDeletion: bool, + YTReplacementDomain: str, + twitterReplacementDomain: str, + peertubeInstances: [], + allowLocalNetworkAccess: bool, + themeName: str, systemLanguage: str, + maxLikeCount: int, CWlists: {}, + listsEnabled: str) -> bool: + """Receives an undo emoji reaction within the POST section of HTTPServer + """ + if messageJson['type'] != 'Undo': + return False + if not hasActor(messageJson, debug): + return False + if not hasObjectStringType(messageJson, debug): + return False + if messageJson['object']['type'] != 'EmojiReact': + return False + if not hasObjectStringObject(messageJson, debug): + return False + if not messageJson['object'].get('content'): + if debug: + print('DEBUG: ' + messageJson['type'] + ' has no "content"') + return False + if not isinstance(messageJson['object']['content'], str): + if debug: + print('DEBUG: ' + messageJson['type'] + ' content is not string') + return False + if not hasUsersPath(messageJson['actor']): + if debug: + print('DEBUG: "users" or "profile" missing from actor in ' + + messageJson['type'] + ' reaction') + return False + if '/statuses/' not in messageJson['object']['object']: + if debug: + print('DEBUG: "statuses" missing from reaction object in ' + + messageJson['type']) + return False + if not os.path.isdir(baseDir + '/accounts/' + handle): + print('DEBUG: unknown recipient of undo reaction - ' + handle) + # if this post in the outbox of the person? + handleName = handle.split('@')[0] + handleDom = handle.split('@')[1] + postFilename = \ + locatePost(baseDir, handleName, handleDom, + messageJson['object']['object']) + if not postFilename: + if debug: + print('DEBUG: unreaction post not found in inbox or outbox') + print(messageJson['object']['object']) + return True + if debug: + print('DEBUG: reaction post found in inbox. Now undoing.') + reactionActor = messageJson['actor'] + postReactionId = messageJson['object'] + emojiContent = removeHtml(messageJson['object']['content']) + if not emojiContent: + if debug: + print('DEBUG: unreaction has no content') + return True + undoReactionCollectionEntry(recentPostsCache, baseDir, postFilename, + postReactionId, reactionActor, domain, + debug, None, emojiContent) + # regenerate the html + reactionPostJson = loadJson(postFilename, 0, 1) + if reactionPostJson: + if reactionPostJson.get('type'): + if reactionPostJson['type'] == 'Announce' and \ + reactionPostJson.get('object'): + if isinstance(reactionPostJson['object'], str): + announceReactionUrl = reactionPostJson['object'] + announceReactionFilename = \ + locatePost(baseDir, handleName, + domain, announceReactionUrl) + if announceReactionFilename: + postReactionId = announceReactionUrl + postFilename = announceReactionFilename + undoReactionCollectionEntry(recentPostsCache, baseDir, + postFilename, + postReactionId, + reactionActor, domain, + debug, None, + emojiContent) + if reactionPostJson: + if debug: + cachedPostFilename = \ + getCachedPostFilename(baseDir, handleName, domain, + reactionPostJson) + print('Unreaction post json: ' + str(reactionPostJson)) + print('Unreaction post nickname: ' + handleName + ' ' + domain) + print('Unreaction post cache: ' + str(cachedPostFilename)) + pageNumber = 1 + showPublishedDateOnly = False + showIndividualPostIcons = True + manuallyApproveFollowers = \ + followerApprovalActive(baseDir, handleName, domain) + notDM = not isDM(reactionPostJson) + individualPostAsHtml(signingPrivateKeyPem, False, + recentPostsCache, maxRecentPosts, + translate, pageNumber, baseDir, + session, cachedWebfingers, personCache, + handleName, domain, port, reactionPostJson, + None, True, allowDeletion, + httpPrefix, __version__, + 'inbox', + YTReplacementDomain, + twitterReplacementDomain, + showPublishedDateOnly, + peertubeInstances, + allowLocalNetworkAccess, + themeName, systemLanguage, + maxLikeCount, notDM, + showIndividualPostIcons, + manuallyApproveFollowers, + False, True, False, CWlists, + listsEnabled) + return True + + def _receiveBookmark(recentPostsCache: {}, session, handle: str, isGroup: bool, baseDir: str, httpPrefix: str, domain: str, port: int, @@ -2068,6 +2344,40 @@ def _alreadyLiked(baseDir: str, nickname: str, domain: str, return False +def _alreadyReacted(baseDir: str, nickname: str, domain: str, + postUrl: str, reactionActor: str, + emojiContent: str) -> bool: + """Is the given post already emoji reacted by the given handle? + """ + postFilename = \ + locatePost(baseDir, nickname, domain, postUrl) + if not postFilename: + return False + postJsonObject = loadJson(postFilename, 1) + if not postJsonObject: + return False + if not hasObjectDict(postJsonObject): + return False + if not postJsonObject['object'].get('reactions'): + return False + if not postJsonObject['object']['reactions'].get('items'): + return False + for react in postJsonObject['object']['reactions']['items']: + if not react.get('type'): + continue + if not react.get('content'): + continue + if not react.get('actor'): + continue + if react['type'] != 'EmojiReact': + continue + if react['content'] != emojiContent: + continue + if react['actor'] == reactionActor: + return True + return False + + def _likeNotify(baseDir: str, domain: str, onionDomain: str, handle: str, actor: str, url: str) -> None: """Creates a notification that a like has arrived @@ -2130,6 +2440,71 @@ def _likeNotify(baseDir: str, domain: str, onionDomain: str, pass +def _reactionNotify(baseDir: str, domain: str, onionDomain: str, + handle: str, actor: str, + url: str, emojiContent: str) -> None: + """Creates a notification that an emoji reaction has arrived + """ + # This is not you reacting to your own post + if actor in url: + return + + # check that the reaction post was by this handle + nickname = handle.split('@')[0] + if '/' + domain + '/users/' + nickname not in url: + if not onionDomain: + return + if '/' + onionDomain + '/users/' + nickname not in url: + return + + accountDir = baseDir + '/accounts/' + handle + + # are reaction notifications enabled? + notifyReactionEnabledFilename = accountDir + '/.notifyReactions' + if not os.path.isfile(notifyReactionEnabledFilename): + return + + reactionFile = accountDir + '/.newReaction' + if os.path.isfile(reactionFile): + if '##sent##' not in open(reactionFile).read(): + return + + reactionNickname = getNicknameFromActor(actor) + reactionDomain, reactionPort = getDomainFromActor(actor) + if reactionNickname and reactionDomain: + reactionHandle = reactionNickname + '@' + reactionDomain + else: + print('_reactionNotify reactionHandle: ' + + str(reactionNickname) + '@' + str(reactionDomain)) + reactionHandle = actor + if reactionHandle != handle: + reactionStr = \ + reactionHandle + ' ' + url + '?reactBy=' + actor + \ + ';emoj=' + emojiContent + prevReactionFile = accountDir + '/.prevReaction' + # was there a previous reaction notification? + if os.path.isfile(prevReactionFile): + # is it the same as the current notification ? + with open(prevReactionFile, 'r') as fp: + prevReactionStr = fp.read() + if prevReactionStr == reactionStr: + return + try: + with open(prevReactionFile, 'w+') as fp: + fp.write(reactionStr) + except BaseException: + print('EX: ERROR: unable to save previous reaction notification ' + + prevReactionFile) + pass + try: + with open(reactionFile, 'w+') as fp: + fp.write(reactionStr) + except BaseException: + print('EX: ERROR: unable to write reaction notification file ' + + reactionFile) + pass + + def _notifyPostArrival(baseDir: str, handle: str, url: str) -> None: """Creates a notification that a new post has arrived. This is for followed accounts with the notify checkbox enabled @@ -2834,6 +3209,51 @@ def _inboxAfterInitial(recentPostsCache: {}, maxRecentPosts: int, print('DEBUG: Undo like accepted from ' + actor) return False + if _receiveReaction(recentPostsCache, + session, handle, isGroup, + baseDir, httpPrefix, + domain, port, + onionDomain, + sendThreads, postLog, + cachedWebfingers, + personCache, + messageJson, + federationList, + debug, signingPrivateKeyPem, + maxRecentPosts, translate, + allowDeletion, + YTReplacementDomain, + twitterReplacementDomain, + peertubeInstances, + allowLocalNetworkAccess, + themeName, systemLanguage, + maxLikeCount, CWlists, listsEnabled): + if debug: + print('DEBUG: Reaction accepted from ' + actor) + return False + + if _receiveUndoReaction(recentPostsCache, + session, handle, isGroup, + baseDir, httpPrefix, + domain, port, + sendThreads, postLog, + cachedWebfingers, + personCache, + messageJson, + federationList, + debug, signingPrivateKeyPem, + maxRecentPosts, translate, + allowDeletion, + YTReplacementDomain, + twitterReplacementDomain, + peertubeInstances, + allowLocalNetworkAccess, + themeName, systemLanguage, + maxLikeCount, CWlists, listsEnabled): + if debug: + print('DEBUG: Undo reaction accepted from ' + actor) + return False + if _receiveBookmark(recentPostsCache, session, handle, isGroup, baseDir, httpPrefix, diff --git a/like.py b/like.py index e1df5a5de..ef1c57a5d 100644 --- a/like.py +++ b/like.py @@ -35,6 +35,22 @@ from auth import createBasicAuthHeader from posts import getPersonBox +def noOfLikes(postJsonObject: {}) -> int: + """Returns the number of likes ona given post + """ + obj = postJsonObject + if hasObjectDict(postJsonObject): + obj = postJsonObject['object'] + if not obj.get('likes'): + return 0 + if not isinstance(obj['likes'], dict): + return 0 + if not obj['likes'].get('items'): + obj['likes']['items'] = [] + obj['likes']['totalItems'] = 0 + return len(obj['likes']['items']) + + def likedByPerson(postJsonObject: {}, nickname: str, domain: str) -> bool: """Returns True if the given post is liked by the given person """ @@ -52,22 +68,6 @@ def likedByPerson(postJsonObject: {}, nickname: str, domain: str) -> bool: return False -def noOfLikes(postJsonObject: {}) -> int: - """Returns the number of likes ona given post - """ - obj = postJsonObject - if hasObjectDict(postJsonObject): - obj = postJsonObject['object'] - if not obj.get('likes'): - return 0 - if not isinstance(obj['likes'], dict): - return 0 - if not obj['likes'].get('items'): - obj['likes']['items'] = [] - obj['likes']['totalItems'] = 0 - return len(obj['likes']['items']) - - def _like(recentPostsCache: {}, session, baseDir: str, federationList: [], nickname: str, domain: str, port: int, 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) + '" ' + diff --git a/outbox.py b/outbox.py index 6b0bc0583..f9f42de9f 100644 --- a/outbox.py +++ b/outbox.py @@ -48,6 +48,8 @@ from skills import outboxSkills from availability import outboxAvailability from like import outboxLike from like import outboxUndoLike +from reaction import outboxReaction +from reaction import outboxUndoReaction from bookmarks import outboxBookmark from bookmarks import outboxUndoBookmark from delete import outboxDelete @@ -338,9 +340,10 @@ def postMessageToOutbox(session, translate: {}, '/system/' + 'media_attachments/files/') - permittedOutboxTypes = ('Create', 'Announce', 'Like', 'Follow', 'Undo', - 'Update', 'Add', 'Remove', 'Block', 'Delete', - 'Skill', 'Ignore') + permittedOutboxTypes = ( + 'Create', 'Announce', 'Like', 'EmojiReact', 'Follow', 'Undo', + 'Update', 'Add', 'Remove', 'Block', 'Delete', 'Skill', 'Ignore' + ) if messageJson['type'] not in permittedOutboxTypes: if debug: print('DEBUG: POST to outbox - ' + messageJson['type'] + @@ -547,6 +550,20 @@ def postMessageToOutbox(session, translate: {}, baseDir, httpPrefix, postToNickname, domain, port, messageJson, debug) + + if debug: + print('DEBUG: handle any emoji reaction requests') + outboxReaction(recentPostsCache, + baseDir, httpPrefix, + postToNickname, domain, port, + messageJson, debug) + if debug: + print('DEBUG: handle any undo emoji reaction requests') + outboxUndoReaction(recentPostsCache, + baseDir, httpPrefix, + postToNickname, domain, port, + messageJson, debug) + if debug: print('DEBUG: handle any undo announce requests') outboxUndoAnnounce(recentPostsCache, 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/posts.py b/posts.py index 38be89bd0..347821b10 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) @@ -1581,7 +1581,6 @@ def undoPinnedPost(baseDir: str, nickname: str, domain: str) -> None: os.remove(pinnedFilename) except BaseException: print('EX: undoPinnedPost unable to delete ' + pinnedFilename) - pass def getPinnedPostAsJson(baseDir: str, httpPrefix: str, @@ -3507,6 +3506,11 @@ def removePostInteractions(postJsonObject: {}, force: bool) -> bool: postObj['likes'] = { 'items': [] } + # clear the reactions + if postObj.get('reactions'): + postObj['reactions'] = { + 'items': [] + } # remove other collections removeCollections = ( 'replies', 'shares', 'bookmarks', 'ignores' diff --git a/reaction.py b/reaction.py new file mode 100644 index 000000000..536dbc06e --- /dev/null +++ b/reaction.py @@ -0,0 +1,564 @@ +__filename__ = "reaction.py" +__author__ = "Bob Mottram" +__license__ = "AGPL3+" +__version__ = "1.2.0" +__maintainer__ = "Bob Mottram" +__email__ = "bob@libreserver.org" +__status__ = "Production" +__module_group__ = "ActivityPub" + +import os +import re +import urllib.parse +from pprint import pprint +from utils import hasObjectString +from utils import hasObjectStringObject +from utils import hasObjectStringType +from utils import removeDomainPort +from utils import hasObjectDict +from utils import hasUsersPath +from utils import getFullDomain +from utils import removeIdEnding +from utils import urlPermitted +from utils import getNicknameFromActor +from utils import getDomainFromActor +from utils import locatePost +from utils import undoReactionCollectionEntry +from utils import hasGroupType +from utils import localActorUrl +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 +from auth import createBasicAuthHeader +from posts import getPersonBox + +# the maximum number of reactions from individual actors which can be +# added to a post. Hence an adversary can't bombard you with sockpuppet +# generated reactions and make the post infeasibly large +maxActorReactionsPerPost = 64 + +# regex defining permissable emoji icon range +emojiRegex = re.compile(r'[\u263a-\U0001f645]') + + +def validEmojiContent(emojiContent: str) -> bool: + """Is the given emoji content valid? + """ + if not emojiContent: + return False + if len(emojiContent) > 1: + return False + if len(emojiRegex.findall(emojiContent)) == 0: + return False + if containsInvalidChars(emojiContent): + return False + return True + + +def _reaction(recentPostsCache: {}, + session, baseDir: str, federationList: [], + nickname: str, domain: str, port: int, + ccList: [], httpPrefix: str, + objectUrl: str, emojiContent: str, + actorReaction: str, + clientToServer: bool, + sendThreads: [], postLog: [], + personCache: {}, cachedWebfingers: {}, + debug: bool, projectVersion: str, + signingPrivateKeyPem: str) -> {}: + """Creates an emoji reaction + actor is the person doing the reacting + 'to' might be a specific person (actor) whose post was reaction + object is typically the url of the message which was reaction + """ + if not urlPermitted(objectUrl, federationList): + return None + if not validEmojiContent(emojiContent): + print('_reaction: Invalid emoji reaction: "' + emojiContent + '"') + return + + fullDomain = getFullDomain(domain, port) + + newReactionJson = { + "@context": "https://www.w3.org/ns/activitystreams", + 'type': 'EmojiReact', + 'actor': localActorUrl(httpPrefix, nickname, fullDomain), + 'object': objectUrl, + 'content': emojiContent + } + if ccList: + if len(ccList) > 0: + newReactionJson['cc'] = ccList + + # Extract the domain and nickname from a statuses link + reactionPostNickname = None + reactionPostDomain = None + reactionPostPort = None + groupAccount = False + if actorReaction: + reactionPostNickname = getNicknameFromActor(actorReaction) + reactionPostDomain, reactionPostPort = \ + getDomainFromActor(actorReaction) + groupAccount = hasGroupType(baseDir, actorReaction, personCache) + else: + if hasUsersPath(objectUrl): + reactionPostNickname = getNicknameFromActor(objectUrl) + reactionPostDomain, reactionPostPort = \ + getDomainFromActor(objectUrl) + if '/' + str(reactionPostNickname) + '/' in objectUrl: + actorReaction = \ + objectUrl.split('/' + reactionPostNickname + '/')[0] + \ + '/' + reactionPostNickname + groupAccount = \ + hasGroupType(baseDir, actorReaction, personCache) + + if reactionPostNickname: + postFilename = locatePost(baseDir, nickname, domain, objectUrl) + if not postFilename: + print('DEBUG: reaction baseDir: ' + baseDir) + print('DEBUG: reaction nickname: ' + nickname) + print('DEBUG: reaction domain: ' + domain) + print('DEBUG: reaction objectUrl: ' + objectUrl) + return None + + updateReactionCollection(recentPostsCache, + baseDir, postFilename, objectUrl, + newReactionJson['actor'], + nickname, domain, debug, None, + emojiContent) + + sendSignedJson(newReactionJson, session, baseDir, + nickname, domain, port, + reactionPostNickname, + reactionPostDomain, reactionPostPort, + 'https://www.w3.org/ns/activitystreams#Public', + httpPrefix, True, clientToServer, federationList, + sendThreads, postLog, cachedWebfingers, personCache, + debug, projectVersion, None, groupAccount, + signingPrivateKeyPem, 7165392) + + return newReactionJson + + +def reactionPost(recentPostsCache: {}, + session, baseDir: str, federationList: [], + nickname: str, domain: str, port: int, httpPrefix: str, + reactionNickname: str, reactionDomain: str, reactionPort: int, + ccList: [], + reactionStatusNumber: int, emojiContent: str, + clientToServer: bool, + sendThreads: [], postLog: [], + personCache: {}, cachedWebfingers: {}, + debug: bool, projectVersion: str, + signingPrivateKeyPem: str) -> {}: + """Adds a reaction to a given status post. This is only used by unit tests + """ + reactionDomain = getFullDomain(reactionDomain, reactionPort) + + actorReaction = localActorUrl(httpPrefix, reactionNickname, reactionDomain) + objectUrl = actorReaction + '/statuses/' + str(reactionStatusNumber) + + return _reaction(recentPostsCache, + session, baseDir, federationList, + nickname, domain, port, + ccList, httpPrefix, objectUrl, emojiContent, + actorReaction, clientToServer, + sendThreads, postLog, personCache, cachedWebfingers, + debug, projectVersion, signingPrivateKeyPem) + + +def sendReactionViaServer(baseDir: str, session, + fromNickname: str, password: str, + fromDomain: str, fromPort: int, + httpPrefix: str, reactionUrl: str, + emojiContent: str, + cachedWebfingers: {}, personCache: {}, + debug: bool, projectVersion: str, + signingPrivateKeyPem: str) -> {}: + """Creates a reaction via c2s + """ + if not session: + print('WARN: No session for sendReactionViaServer') + return 6 + if not validEmojiContent(emojiContent): + print('sendReactionViaServer: Invalid emoji reaction: "' + + emojiContent + '"') + return 7 + + fromDomainFull = getFullDomain(fromDomain, fromPort) + + actor = localActorUrl(httpPrefix, fromNickname, fromDomainFull) + + newReactionJson = { + "@context": "https://www.w3.org/ns/activitystreams", + 'type': 'EmojiReact', + 'actor': actor, + 'object': reactionUrl, + 'content': emojiContent + } + + handle = httpPrefix + '://' + fromDomainFull + '/@' + fromNickname + + # lookup the inbox for the To handle + wfRequest = webfingerHandle(session, handle, httpPrefix, + cachedWebfingers, + fromDomain, projectVersion, debug, False, + signingPrivateKeyPem) + if not wfRequest: + if debug: + print('DEBUG: reaction webfinger failed for ' + handle) + return 1 + if not isinstance(wfRequest, dict): + print('WARN: reaction webfinger for ' + handle + + ' did not return a dict. ' + str(wfRequest)) + return 1 + + postToBox = 'outbox' + + # get the actor inbox for the To handle + originDomain = fromDomain + (inboxUrl, pubKeyId, pubKey, fromPersonId, sharedInbox, avatarUrl, + displayName, _) = getPersonBox(signingPrivateKeyPem, + originDomain, + baseDir, session, wfRequest, + personCache, + projectVersion, httpPrefix, + fromNickname, fromDomain, + postToBox, 72873) + + if not inboxUrl: + if debug: + print('DEBUG: reaction no ' + postToBox + + ' was found for ' + handle) + return 3 + if not fromPersonId: + if debug: + print('DEBUG: reaction no actor was found for ' + handle) + return 4 + + authHeader = createBasicAuthHeader(fromNickname, password) + + headers = { + 'host': fromDomain, + 'Content-type': 'application/json', + 'Authorization': authHeader + } + postResult = postJson(httpPrefix, fromDomainFull, + session, newReactionJson, [], inboxUrl, + headers, 3, True) + if not postResult: + if debug: + print('WARN: POST reaction failed for c2s to ' + inboxUrl) + return 5 + + if debug: + print('DEBUG: c2s POST reaction success') + + return newReactionJson + + +def sendUndoReactionViaServer(baseDir: str, session, + fromNickname: str, password: str, + fromDomain: str, fromPort: int, + httpPrefix: str, reactionUrl: str, + emojiContent: str, + cachedWebfingers: {}, personCache: {}, + debug: bool, projectVersion: str, + signingPrivateKeyPem: str) -> {}: + """Undo a reaction via c2s + """ + if not session: + print('WARN: No session for sendUndoReactionViaServer') + return 6 + + fromDomainFull = getFullDomain(fromDomain, fromPort) + + actor = localActorUrl(httpPrefix, fromNickname, fromDomainFull) + + newUndoReactionJson = { + "@context": "https://www.w3.org/ns/activitystreams", + 'type': 'Undo', + 'actor': actor, + 'object': { + 'type': 'EmojiReact', + 'actor': actor, + 'object': reactionUrl, + 'content': emojiContent + } + } + + handle = httpPrefix + '://' + fromDomainFull + '/@' + fromNickname + + # lookup the inbox for the To handle + wfRequest = webfingerHandle(session, handle, httpPrefix, + cachedWebfingers, + fromDomain, projectVersion, debug, False, + signingPrivateKeyPem) + if not wfRequest: + if debug: + print('DEBUG: unreaction webfinger failed for ' + handle) + return 1 + if not isinstance(wfRequest, dict): + if debug: + print('WARN: unreaction webfinger for ' + handle + + ' did not return a dict. ' + str(wfRequest)) + return 1 + + postToBox = 'outbox' + + # get the actor inbox for the To handle + originDomain = fromDomain + (inboxUrl, pubKeyId, pubKey, fromPersonId, sharedInbox, avatarUrl, + displayName, _) = getPersonBox(signingPrivateKeyPem, + originDomain, + baseDir, session, wfRequest, + personCache, projectVersion, + httpPrefix, fromNickname, + fromDomain, postToBox, + 72625) + + if not inboxUrl: + if debug: + print('DEBUG: unreaction no ' + postToBox + + ' was found for ' + handle) + return 3 + if not fromPersonId: + if debug: + print('DEBUG: unreaction no actor was found for ' + handle) + return 4 + + authHeader = createBasicAuthHeader(fromNickname, password) + + headers = { + 'host': fromDomain, + 'Content-type': 'application/json', + 'Authorization': authHeader + } + postResult = postJson(httpPrefix, fromDomainFull, + session, newUndoReactionJson, [], inboxUrl, + headers, 3, True) + if not postResult: + if debug: + print('WARN: POST unreaction failed for c2s to ' + inboxUrl) + return 5 + + if debug: + print('DEBUG: c2s POST unreaction success') + + return newUndoReactionJson + + +def outboxReaction(recentPostsCache: {}, + baseDir: str, httpPrefix: str, + nickname: str, domain: str, port: int, + messageJson: {}, debug: bool) -> None: + """ When a reaction request is received by the outbox from c2s + """ + if not messageJson.get('type'): + if debug: + print('DEBUG: reaction - no type') + return + if not messageJson['type'] == 'EmojiReact': + if debug: + print('DEBUG: not a reaction') + return + if not hasObjectString(messageJson, debug): + return + if not messageJson.get('content'): + return + if not isinstance(messageJson['content'], str): + return + if not validEmojiContent(messageJson['content']): + print('outboxReaction: Invalid emoji reaction: "' + + messageJson['content'] + '"') + return + if debug: + print('DEBUG: c2s reaction request arrived in outbox') + + messageId = removeIdEnding(messageJson['object']) + domain = removeDomainPort(domain) + emojiContent = messageJson['content'] + postFilename = locatePost(baseDir, nickname, domain, messageId) + if not postFilename: + if debug: + print('DEBUG: c2s reaction post not found in inbox or outbox') + print(messageId) + return True + updateReactionCollection(recentPostsCache, + baseDir, postFilename, messageId, + messageJson['actor'], + nickname, domain, debug, None, emojiContent) + if debug: + print('DEBUG: post reaction via c2s - ' + postFilename) + + +def outboxUndoReaction(recentPostsCache: {}, + baseDir: str, httpPrefix: str, + nickname: str, domain: str, port: int, + messageJson: {}, debug: bool) -> None: + """ When an undo reaction request is received by the outbox from c2s + """ + if not messageJson.get('type'): + return + if not messageJson['type'] == 'Undo': + return + if not hasObjectStringType(messageJson, debug): + return + if not messageJson['object']['type'] == 'EmojiReact': + if debug: + print('DEBUG: not a undo reaction') + return + if not messageJson['object'].get('content'): + return + if not isinstance(messageJson['object']['content'], str): + return + if not hasObjectStringObject(messageJson, debug): + return + if debug: + print('DEBUG: c2s undo reaction request arrived in outbox') + + messageId = removeIdEnding(messageJson['object']['object']) + emojiContent = messageJson['object']['content'] + domain = removeDomainPort(domain) + postFilename = locatePost(baseDir, nickname, domain, messageId) + if not postFilename: + if debug: + print('DEBUG: c2s undo reaction post not found in inbox or outbox') + print(messageId) + return True + undoReactionCollectionEntry(recentPostsCache, baseDir, postFilename, + messageId, messageJson['actor'], + domain, debug, None, emojiContent) + if debug: + print('DEBUG: post undo reaction via c2s - ' + postFilename) + + +def updateReactionCollection(recentPostsCache: {}, + baseDir: str, postFilename: str, + objectUrl: str, actor: str, + nickname: str, domain: str, debug: bool, + postJsonObject: {}, + emojiContent: str) -> None: + """Updates the reactions collection within a post + """ + if not postJsonObject: + postJsonObject = loadJson(postFilename) + if not postJsonObject: + return + + # remove any cached version of this post so that the + # reaction icon is changed + removePostFromCache(postJsonObject, recentPostsCache) + cachedPostFilename = getCachedPostFilename(baseDir, nickname, + domain, postJsonObject) + if cachedPostFilename: + if os.path.isfile(cachedPostFilename): + try: + os.remove(cachedPostFilename) + except BaseException: + print('EX: updateReactionCollection unable to delete ' + + cachedPostFilename) + pass + + obj = postJsonObject + if hasObjectDict(postJsonObject): + obj = postJsonObject['object'] + + if not objectUrl.endswith('/reactions'): + objectUrl = objectUrl + '/reactions' + if not obj.get('reactions'): + if debug: + print('DEBUG: Adding initial emoji reaction to ' + objectUrl) + reactionsJson = { + "@context": "https://www.w3.org/ns/activitystreams", + 'id': objectUrl, + 'type': 'Collection', + "totalItems": 1, + 'items': [{ + 'type': 'EmojiReact', + 'actor': actor, + 'content': emojiContent + }] + } + obj['reactions'] = reactionsJson + else: + if not obj['reactions'].get('items'): + obj['reactions']['items'] = [] + # upper limit for the number of reactions on a post + if len(obj['reactions']['items']) >= maxActorReactionsPerPost: + return + for reactionItem in obj['reactions']['items']: + if reactionItem.get('actor') and reactionItem.get('content'): + if reactionItem['actor'] == actor and \ + reactionItem['content'] == emojiContent: + # already reaction + return + newReaction = { + 'type': 'EmojiReact', + 'actor': actor, + 'content': emojiContent + } + obj['reactions']['items'].append(newReaction) + itlen = len(obj['reactions']['items']) + obj['reactions']['totalItems'] = itlen + + if debug: + print('DEBUG: saving post with emoji reaction added') + pprint(postJsonObject) + saveJson(postJsonObject, postFilename) + + +def htmlEmojiReactions(postJsonObject: {}, interactive: bool, + actor: str, maxReactionTypes: int) -> str: + """html containing row of emoji reactions + """ + if not hasObjectDict(postJsonObject): + return '' + if not postJsonObject['object'].get('reactions'): + return '' + 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 + else: + reactions[emojiContent] += 1 + if len(reactions.items()) == 0: + return '' + reactBy = removeIdEnding(postJsonObject['object']['id']) + htmlStr = '