From f399fb579c3ffdb6db0f4b68db0fc2bdec68b6b7 Mon Sep 17 00:00:00 2001 From: Grishka Date: Thu, 18 Apr 2024 18:37:47 +0300 Subject: [PATCH 001/203] Optimize newsfeed query --- src/main/java/smithereen/storage/PostStorage.java | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/main/java/smithereen/storage/PostStorage.java b/src/main/java/smithereen/storage/PostStorage.java index 7d8fa83e..bcbf7f74 100644 --- a/src/main/java/smithereen/storage/PostStorage.java +++ b/src/main/java/smithereen/storage/PostStorage.java @@ -265,10 +265,8 @@ public static List getFeed(int userID, int startFromID, int offse try(DatabaseConnection conn=DatabaseConnectionManager.getConnection()){ PreparedStatement stmt; if(total!=null){ - stmt=conn.prepareStatement("SELECT COUNT(*) FROM `newsfeed` WHERE `author_id` IN (SELECT followee_id FROM followings WHERE follower_id=? UNION SELECT ?) AND `id`<=? AND `time`>DATE_SUB(CURRENT_TIMESTAMP(), INTERVAL 10 DAY)"); - stmt.setInt(1, userID); - stmt.setInt(2, userID); - stmt.setInt(3, startFromID==0 ? Integer.MAX_VALUE : startFromID); + stmt=SQLQueryBuilder.prepareStatement(conn, "SELECT COUNT(*) FROM `newsfeed` WHERE (`author_id` IN (SELECT followee_id FROM followings WHERE follower_id=?) OR (type=0 AND author_id=?)) AND `id`<=? AND `time`>DATE_SUB(CURRENT_TIMESTAMP(), INTERVAL 10 DAY)", + userID, userID, startFromID==0 ? Integer.MAX_VALUE : startFromID); try(ResultSet res=stmt.executeQuery()){ res.next(); total[0]=res.getInt(1); From 1be220a0a338c046246e5dae803b626a4202799d Mon Sep 17 00:00:00 2001 From: Grishka Date: Fri, 19 Apr 2024 23:56:52 +0300 Subject: [PATCH 002/203] Store Announce reposts as posts + new reposts UI --- schema.sql | 4 +- src/main/java/smithereen/Utils.java | 11 ++- .../handlers/AnnounceNoteHandler.java | 51 +++++++---- .../handlers/UndoAnnounceNoteHandler.java | 27 +++++- .../UserInteractionsController.java | 6 ++ .../controllers/WallController.java | 85 +++++++++++++++++-- src/main/java/smithereen/model/Post.java | 16 ++++ .../model/viewmodel/PostViewModel.java | 9 ++ .../java/smithereen/routes/GroupsRoutes.java | 1 + .../java/smithereen/routes/PostRoutes.java | 19 ++++- .../java/smithereen/routes/ProfileRoutes.java | 1 + .../storage/DatabaseSchemaUpdater.java | 5 +- .../java/smithereen/storage/GroupStorage.java | 2 +- .../java/smithereen/storage/PostStorage.java | 22 +++++ .../templates/PictureForAvatarFilter.java | 9 +- src/main/resources/langs/en/wall.json | 5 +- src/main/resources/langs/ru/wall.json | 5 +- .../templates/desktop/wall_post.twig | 34 ++------ .../templates/desktop/wall_post_form.twig | 14 +-- .../templates/desktop/wall_post_inner.twig | 42 +++++++++ .../resources/templates/mobile/wall_post.twig | 23 +---- .../templates/mobile/wall_post_inner.twig | 42 +++++++++ src/main/web/common.scss | 8 +- src/main/web/desktop.scss | 53 ++++++++++++ src/main/web/img/repost_icons.svg | 16 ++++ src/main/web/img/repost_icons_mobile.svg | 7 ++ src/main/web/mobile.scss | 26 ++++++ 27 files changed, 443 insertions(+), 100 deletions(-) create mode 100644 src/main/resources/templates/desktop/wall_post_inner.twig create mode 100644 src/main/resources/templates/mobile/wall_post_inner.twig create mode 100644 src/main/web/img/repost_icons.svg create mode 100644 src/main/web/img/repost_icons_mobile.svg diff --git a/schema.sql b/schema.sql index 3a42ae44..7e327b02 100644 --- a/schema.sql +++ b/schema.sql @@ -757,6 +757,7 @@ CREATE TABLE `wall_posts` ( `source` text, `source_format` tinyint unsigned DEFAULT NULL, `privacy` tinyint unsigned NOT NULL DEFAULT '0', + `flags` bigint unsigned NOT NULL DEFAULT '0', PRIMARY KEY (`id`), UNIQUE KEY `ap_id` (`ap_id`), KEY `owner_user_id` (`owner_user_id`), @@ -766,10 +767,9 @@ CREATE TABLE `wall_posts` ( KEY `owner_group_id` (`owner_group_id`), KEY `poll_id` (`poll_id`), CONSTRAINT `wall_posts_ibfk_1` FOREIGN KEY (`owner_user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, - CONSTRAINT `wall_posts_ibfk_2` FOREIGN KEY (`repost_of`) REFERENCES `wall_posts` (`id`) ON DELETE SET NULL, CONSTRAINT `wall_posts_ibfk_4` FOREIGN KEY (`owner_group_id`) REFERENCES `groups` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; /*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; --- Dump completed on 2024-03-23 8:50:17 +-- Dump completed on 2024-04-19 5:12:04 diff --git a/src/main/java/smithereen/Utils.java b/src/main/java/smithereen/Utils.java index 9698f247..28ef46ba 100644 --- a/src/main/java/smithereen/Utils.java +++ b/src/main/java/smithereen/Utils.java @@ -969,12 +969,13 @@ public static Instant instantFromDateAndTime(Request req, String dateStr, String return LocalDateTime.of(date, time).atZone(timeZoneForRequest(req)).toInstant(); } - public static > long serializeEnumSet(EnumSet set, Class cls){ - if(cls.getEnumConstants().length>64) - throw new IllegalArgumentException("this enum has more than 64 constants"); + public static > long serializeEnumSet(EnumSet set){ long result=0; for(E value:set){ - result|=1L << value.ordinal(); + int ordinal=value.ordinal(); + if(ordinal>=64) + throw new IllegalArgumentException("this enum has more than 64 constants"); + result|=1L << ordinal; } return result; } @@ -1052,6 +1053,8 @@ public static String substituteLinks(String str, Map links){ link.attr(attr, b); else if(value instanceof String s) link.attr(attr, s); + else if(value!=null) + link.attr(attr, value.toString()); } } return root.html(); diff --git a/src/main/java/smithereen/activitypub/handlers/AnnounceNoteHandler.java b/src/main/java/smithereen/activitypub/handlers/AnnounceNoteHandler.java index 010b746d..f0338433 100644 --- a/src/main/java/smithereen/activitypub/handlers/AnnounceNoteHandler.java +++ b/src/main/java/smithereen/activitypub/handlers/AnnounceNoteHandler.java @@ -3,9 +3,13 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.net.URI; import java.sql.SQLException; import java.time.Instant; import java.util.List; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import smithereen.activitypub.ActivityHandlerContext; import smithereen.activitypub.ActivityTypeHandler; @@ -18,49 +22,66 @@ import smithereen.model.notifications.Notification; import smithereen.storage.NotificationsStorage; import smithereen.storage.PostStorage; +import smithereen.util.UriBuilder; public class AnnounceNoteHandler extends ActivityTypeHandler{ - private static final Logger LOG=LoggerFactory.getLogger(AnnounceNoteHandler.class); - - private Announce activity; - private ForeignUser actor; @Override public void handle(ActivityHandlerContext context, ForeignUser actor, Announce activity, NoteOrQuestion post) throws SQLException{ - this.activity=activity; - this.actor=actor; if(post.inReplyTo!=null){ Post parent=PostStorage.getPostByID(post.inReplyTo); if(parent!=null){ Post nativePost=post.asNativePost(context.appContext); context.appContext.getWallController().loadAndPreprocessRemotePostMentions(nativePost, post); context.appContext.getObjectLinkResolver().storeOrUpdateRemoteObject(nativePost); - doHandle(nativePost, context); + doHandle(nativePost, actor, activity, context); }else{ - context.appContext.getActivityPubWorker().fetchReplyThreadAndThen(post, thread->onReplyThreadDone(thread, context)); + context.appContext.getActivityPubWorker().fetchReplyThreadAndThen(post, thread->onReplyThreadDone(thread, actor, activity, context)); } }else{ Post nativePost=post.asNativePost(context.appContext); context.appContext.getWallController().loadAndPreprocessRemotePostMentions(nativePost, post); context.appContext.getObjectLinkResolver().storeOrUpdateRemoteObject(nativePost); - doHandle(nativePost, context); + doHandle(nativePost, actor, activity, context); context.appContext.getActivityPubWorker().fetchAllReplies(nativePost); } } - private void onReplyThreadDone(List thread, ActivityHandlerContext context){ + private void onReplyThreadDone(List thread, ForeignUser actor, Announce activity, ActivityHandlerContext context){ try{ - doHandle(thread.get(thread.size()-1), context); + doHandle(thread.get(thread.size()-1), actor, activity, context); }catch(SQLException x){ LOG.warn("Error storing retoot", x); } } - private void doHandle(Post post, ActivityHandlerContext context) throws SQLException{ - long time=activity.published==null ? System.currentTimeMillis() : activity.published.toEpochMilli(); - context.appContext.getNewsfeedController().putFriendsFeedEntry(actor, post.id, NewsfeedEntry.Type.RETOOT, Instant.ofEpochMilli(time)); - User author=context.appContext.getUsersController().getUserOrThrow(post.authorID); + private void doHandle(Post post, ForeignUser actor, Announce activity, ActivityHandlerContext context) throws SQLException{ + Post repost=new Post(); + repost.authorID=repost.ownerID=actor.id; + repost.repostOf=post.id; + repost.flags.add(Post.Flag.MASTODON_STYLE_REPOST); + repost.createdAt=activity.published==null ? Instant.now() : activity.published; + repost.setActivityPubID(activity.activityPubID); + repost.privacy=post.privacy; + if(activity.url!=null){ + repost.activityPubURL=activity.url; + }else{ + // Remove "/activity" off the end of the activity ID to get the ID for the "pseudo-post". Mastodon and Misskey don't expose IDs of these pseudo-posts themselves. + // I'm very sorry I have to do this. + // Mastodon example: https://raspberry.grishka.me/users/grishka/statuses/112293152682340239/activity + // Misskey example: https://misskey.io/notes/9s92azzu76fm045q/activity + if(activity.activityPubID.getRawPath().endsWith("/activity")){ + String idStr=activity.activityPubID.toString(); + repost.activityPubURL=URI.create(idStr.substring(0, idStr.lastIndexOf('/'))); + }else{ + // Oh no! Anyway... + repost.activityPubURL=activity.activityPubID; + } + } + PostStorage.putForeignWallPost(repost); + context.appContext.getNewsfeedController().clearFriendsFeedCache(); + User author=context.appContext.getUsersController().getUserOrThrow(post.authorID); if(!(author instanceof ForeignUser)){ Notification n=new Notification(); n.type=Notification.Type.RETOOT; diff --git a/src/main/java/smithereen/activitypub/handlers/UndoAnnounceNoteHandler.java b/src/main/java/smithereen/activitypub/handlers/UndoAnnounceNoteHandler.java index aae2c7af..b0dbe939 100644 --- a/src/main/java/smithereen/activitypub/handlers/UndoAnnounceNoteHandler.java +++ b/src/main/java/smithereen/activitypub/handlers/UndoAnnounceNoteHandler.java @@ -7,17 +7,40 @@ import smithereen.activitypub.objects.NoteOrQuestion; import smithereen.activitypub.objects.activities.Announce; import smithereen.activitypub.objects.activities.Undo; +import smithereen.exceptions.BadRequestException; +import smithereen.exceptions.ObjectNotFoundException; +import smithereen.exceptions.UserActionNotAllowedException; import smithereen.model.ForeignUser; import smithereen.model.Post; import smithereen.model.feed.NewsfeedEntry; import smithereen.model.notifications.Notification; import smithereen.storage.NotificationsStorage; +import smithereen.storage.PostStorage; public class UndoAnnounceNoteHandler extends NestedActivityTypeHandler{ @Override public void handle(ActivityHandlerContext context, ForeignUser actor, Undo activity, Announce nested, NoteOrQuestion _post) throws SQLException{ - Post post=context.appContext.getWallController().getPostOrThrow(_post.activityPubID); - context.appContext.getNewsfeedController().deleteFriendsFeedEntry(actor, post.id, NewsfeedEntry.Type.RETOOT); + Post post; + Post repost; + try{ + repost=context.appContext.getWallController().getPostOrThrow(nested.activityPubID); + }catch(ObjectNotFoundException x){ + LOG.debug("Repost {} for Undo{Announce{Note}} not found", nested.activityPubID, x); + return; + } + if(repost.ownerID!=actor.id) + throw new UserActionNotAllowedException("Post "+repost.getActivityPubID()+" is not owned by actor "+actor.activityPubID); + try{ + post=context.appContext.getWallController().getPostOrThrow(_post.activityPubID); + }catch(ObjectNotFoundException x){ + LOG.debug("Reposted post {} for Undo{Announce{Note}} not found", _post.activityPubID, x); + return; + } + if(repost.repostOf!=post.id || !repost.flags.contains(Post.Flag.MASTODON_STYLE_REPOST)) + throw new BadRequestException("Post "+repost.getActivityPubID()+" is not a repost of "+post.getActivityPubID()); + + PostStorage.deletePost(repost.id); + context.appContext.getNewsfeedController().clearFriendsFeedCache(); NotificationsStorage.deleteNotification(Notification.ObjectType.POST, post.id, Notification.Type.RETOOT, actor.id); } } diff --git a/src/main/java/smithereen/controllers/UserInteractionsController.java b/src/main/java/smithereen/controllers/UserInteractionsController.java index f70e30b7..36f96aec 100644 --- a/src/main/java/smithereen/controllers/UserInteractionsController.java +++ b/src/main/java/smithereen/controllers/UserInteractionsController.java @@ -29,6 +29,9 @@ public UserInteractionsController(ApplicationContext context){ public PaginatedList getLikesForObject(Post object, User self, int offset, int count){ try{ + if(object.isMastodonStyleRepost()){ + object=context.getWallController().getPostOrThrow(object.repostOf); + } UserInteractions interactions=PostStorage.getPostInteractions(Collections.singletonList(object.id), 0).get(object.id); List users=UserStorage.getByIdAsList(LikeStorage.getPostLikes(object.id, self!=null ? self.id : 0, offset, count)); return new PaginatedList<>(users, interactions.likeCount, offset, count); @@ -39,6 +42,9 @@ public PaginatedList getLikesForObject(Post object, User self, int offset, public void setObjectLiked(Post object, boolean liked, User self){ try{ + if(object.isMastodonStyleRepost()){ + object=context.getWallController().getPostOrThrow(object.repostOf); + } context.getPrivacyController().enforceObjectPrivacy(self, object); OwnerAndAuthor oaa=context.getWallController().getContentAuthorAndOwner(object); if(oaa.owner() instanceof User u) diff --git a/src/main/java/smithereen/controllers/WallController.java b/src/main/java/smithereen/controllers/WallController.java index 2a6e4acc..b03faba2 100644 --- a/src/main/java/smithereen/controllers/WallController.java +++ b/src/main/java/smithereen/controllers/WallController.java @@ -137,8 +137,12 @@ public Post createWallPost(@NotNull User author, int authorAccountID, @NotNull A throw new BadRequestException("This actor doesn't support wall posts"); Post parent=inReplyToID!=0 ? getPostOrThrow(inReplyToID) : null; - if(parent!=null) + if(parent!=null){ + if(parent.isMastodonStyleRepost()){ + parent=getPostOrThrow(parent.repostOf); + } context.getPrivacyController().enforcePostPrivacy(author, parent); + } final ArrayList mentionedUsers=new ArrayList<>(); String text=preparePostText(textSource, mentionedUsers, parent); @@ -492,10 +496,10 @@ public PaginatedList getWallToWallPosts(@Nullable User self, @NotNull User */ public void populateCommentPreviews(@Nullable User self, @NotNull List posts){ try{ - Set postIDs=posts.stream().map(p->p.post.id).collect(Collectors.toSet()); + Set postIDs=posts.stream().map(p->p.post.getIDForInteractions()).collect(Collectors.toSet()); Map> allComments=PostStorage.getRepliesForFeed(postIDs); for(PostViewModel post:posts){ - PaginatedList comments=allComments.get(post.post.id); + PaginatedList comments=allComments.get(post.post.getIDForInteractions()); if(comments!=null){ context.getPrivacyController().filterPosts(self, comments.list); if(!comments.list.isEmpty()){ @@ -517,19 +521,39 @@ public void populateCommentPreviews(@Nullable User self, @NotNull List getUserInteractions(@NotNull List posts, @Nullable User self){ try{ - Set postIDs=posts.stream().map(p->p.post.id).collect(Collectors.toSet()); + Set postIDs=posts.stream().map(p->p.post.getIDForInteractions()).collect(Collectors.toSet()); Set ownerUserIDs=new HashSet<>(); for(PostViewModel p:posts){ p.getAllReplyIDs(postIDs); - if(p.post.ownerID>0) - ownerUserIDs.add(p.post.ownerID); + if(!p.post.isMastodonStyleRepost()){ + if(p.post.ownerID>0) + ownerUserIDs.add(p.post.ownerID); + }else if(p.repost!=null && p.repost.post()!=null){ + Post repost=p.repost.post().post; + if(repost.ownerID>0) + ownerUserIDs.add(repost.ownerID); + } } Map canComment=context.getUsersController().getUsers(ownerUserIDs) .entrySet() .stream() .collect(Collectors.toMap(Map.Entry::getKey, e->context.getPrivacyController().checkUserPrivacy(self, e.getValue(), UserPrivacySettingKey.WALL_COMMENTING))); for(PostViewModel post:posts){ - post.canComment=canComment.getOrDefault(post.post.ownerID, true); + int ownerID; + if(post.post.isMastodonStyleRepost()){ + if(post.repost!=null && post.repost.post()!=null){ + ownerID=post.repost.post().post.ownerID; + }else{ + ownerID=0; + } + }else{ + ownerID=post.post.ownerID; + } + // Can't comment on posts or in threads that don't exist + if(post.post.isMastodonStyleRepost() && post.repost!=null && (post.repost.post()==null || (post.repost.post().post.getReplyLevel()>0 && post.repost.topLevel()==null))) + post.canComment=false; + else + post.canComment=canComment.getOrDefault(ownerID, true); } return PostStorage.getPostInteractions(postIDs, self!=null ? self.id : 0); }catch(SQLException x){ @@ -702,4 +726,51 @@ public int getPostIDByActivityPubID(URI id){ throw new InternalServerErrorException(x); } } + + public void populateReposts(User self, List posts, int maxDepth){ + HashMap knownPosts=posts.stream().collect(Collectors.toMap(p->p.post.id, Function.identity(), (a, b)->b, HashMap::new)); + HashSet needPosts=new HashSet<>(); + HashSet reposts=new HashSet<>(), nextReposts=new HashSet<>(); + for(PostViewModel post:posts){ + if(post.post.repostOf!=0){ + reposts.add(post); + } + } + for(int i=0;i newPosts=getPosts(needPosts); + needPosts.clear(); + for(Post post:newPosts.values()){ + knownPosts.put(post.id, new PostViewModel(post)); + if(post.getReplyLevel()>0 && !knownPosts.containsKey(post.replyKey.getFirst())){ + needPosts.add(post.replyKey.getFirst()); + } + } + // For comments, get their top-level posts + if(!needPosts.isEmpty()){ + for(Post post:getPosts(needPosts).values()){ + knownPosts.put(post.id, new PostViewModel(post)); + } + } + } + for(PostViewModel post:reposts){ + PostViewModel repost=knownPosts.get(post.post.repostOf); + if(repost!=null){ + PostViewModel topLevel=repost.post.getReplyLevel()>0 ? knownPosts.get(repost.post.replyKey.getFirst()) : null; + post.repost=new PostViewModel.Repost(repost, topLevel); + nextReposts.add(repost); + } + } + HashSet tmp=reposts; + reposts=nextReposts; + nextReposts=tmp; + } + } } diff --git a/src/main/java/smithereen/model/Post.java b/src/main/java/smithereen/model/Post.java index f4f551e0..959851b8 100644 --- a/src/main/java/smithereen/model/Post.java +++ b/src/main/java/smithereen/model/Post.java @@ -10,6 +10,7 @@ import java.time.Instant; import java.util.ArrayList; import java.util.Base64; +import java.util.EnumSet; import java.util.List; import java.util.Set; @@ -48,6 +49,7 @@ public final class Post implements ActivityPubRepresentable, OwnedContentObject, public boolean isReplyToUnknownPost; public boolean deleted; public Privacy privacy=Privacy.PUBLIC; + public EnumSet flags=EnumSet.noneOf(Flag.class); public boolean hasContentWarning(){ return contentWarning!=null; @@ -110,6 +112,7 @@ public static Post fromResultSet(ResultSet res) throws SQLException{ post.poll=PostStorage.getPoll(pollID, post.activityPubID); } post.privacy=Privacy.values()[res.getInt("privacy")]; + Utils.deserializeEnumSet(post.flags, Flag.class, res.getLong("flags")); return post; } @@ -261,6 +264,15 @@ public void fillFromReport(JsonObject jo){ contentWarning=jo.get("cw").getAsString(); } + public boolean isMastodonStyleRepost(){ + return flags.contains(Flag.MASTODON_STYLE_REPOST); + } + + public int getIDForInteractions(){ + // Mastodon-style repost posts can't be interacted with + return flags.contains(Flag.MASTODON_STYLE_REPOST) ? repostOf : id; + } + public enum Privacy{ PUBLIC(null), FOLLOWERS_AND_MENTIONED("post_visible_to_followers_mentioned"), @@ -273,4 +285,8 @@ public enum Privacy{ this.langKey=langKey; } } + + public enum Flag{ + MASTODON_STYLE_REPOST, + } } diff --git a/src/main/java/smithereen/model/viewmodel/PostViewModel.java b/src/main/java/smithereen/model/viewmodel/PostViewModel.java index 22b92f3b..53b1ae69 100644 --- a/src/main/java/smithereen/model/viewmodel/PostViewModel.java +++ b/src/main/java/smithereen/model/viewmodel/PostViewModel.java @@ -15,6 +15,7 @@ public class PostViewModel{ private boolean loadedRepliesCountKnown; private int loadedRepliesCount; public boolean canComment=true; + public Repost repost; public PostViewModel(Post post){ this.post=post; @@ -68,7 +69,15 @@ public static void collectActorIDs(Collection posts, Set userIDs.add(pvm.post.ownerID); else groupIDs.add(-pvm.post.ownerID); + if(pvm.repost!=null){ + if(pvm.repost.post!=null) + collectActorIDs(Set.of(pvm.repost.post), userIDs, groupIDs); + if(pvm.repost.topLevel!=null) + collectActorIDs(Set.of(pvm.repost.topLevel), userIDs, groupIDs); + } collectActorIDs(pvm.repliesObjects, userIDs, groupIDs); } } + + public record Repost(PostViewModel post, PostViewModel topLevel){} } diff --git a/src/main/java/smithereen/routes/GroupsRoutes.java b/src/main/java/smithereen/routes/GroupsRoutes.java index 1e29277f..4e16a62a 100644 --- a/src/main/java/smithereen/routes/GroupsRoutes.java +++ b/src/main/java/smithereen/routes/GroupsRoutes.java @@ -192,6 +192,7 @@ public static Object groupProfile(Request req, Response resp, Group group){ int offset=offset(req); PaginatedList wall=PostViewModel.wrap(ctx.getWallController().getWallPosts(self!=null ? self.user : null, group, false, offset, 20)); wallPostsCount=wall.total; + ctx.getWallController().populateReposts(self!=null ? self.user : null, wall.list, 2); if(req.attribute("mobile")==null){ ctx.getWallController().populateCommentPreviews(self!=null ? self.user : null, wall.list); } diff --git a/src/main/java/smithereen/routes/PostRoutes.java b/src/main/java/smithereen/routes/PostRoutes.java index 0c63a2a0..e68ce391 100644 --- a/src/main/java/smithereen/routes/PostRoutes.java +++ b/src/main/java/smithereen/routes/PostRoutes.java @@ -257,6 +257,7 @@ private static void prepareFeed(ApplicationContext ctx, Request req, Account sel List feedPosts=ctx.getWallController().getPosts(needPosts).values().stream().map(PostViewModel::new).toList(); + ctx.getWallController().populateReposts(self!=null ? self.user : null, feedPosts, 2); if(req.attribute("mobile")==null && !feedPosts.isEmpty()){ ctx.getWallController().populateCommentPreviews(self.user, feedPosts); } @@ -298,7 +299,6 @@ public static Object standalonePost(Request req, Response resp){ else owner=ctx.getUsersController().getUserOrThrow(post.post.ownerID); - User author=ctx.getUsersController().getUserOrThrow(post.post.authorID); RenderedTemplateResponse model=new RenderedTemplateResponse("wall_post_standalone", req); SessionInfo info=Utils.sessionInfo(req); User self=null; @@ -310,11 +310,22 @@ public static Object standalonePost(Request req, Response resp){ self=info.account.user; } + if(post.post.repostOf!=0){ + if(post.post.isMastodonStyleRepost()){ + resp.redirect("/posts/"+post.post.repostOf); + return ""; + } + ctx.getWallController().populateReposts(self, List.of(post), 10); + } + + User author=ctx.getUsersController().getUserOrThrow(post.post.authorID); + int offset=offset(req); PaginatedList replies=ctx.getWallController().getReplies(self, replyKey, offset, 100, 50); model.paginate(replies); model.with("post", post); model.with("isGroup", post.post.ownerID<0); + model.with("maxRepostDepth", 10); int cwCount=0; for(PostViewModel reply:replies.list){ if(reply.post.hasContentWarning()) @@ -456,7 +467,7 @@ public static Object like(Request req, Response resp, Account self, ApplicationC Post post=ctx.getWallController().getPostOrThrow(safeParseInt(req.params("postID"))); ctx.getUserInteractionsController().setObjectLiked(post, true, self.user); if(isAjax(req)){ - UserInteractions interactions=ctx.getWallController().getUserInteractions(List.of(new PostViewModel(post)), self.user).get(post.id); + UserInteractions interactions=ctx.getWallController().getUserInteractions(List.of(new PostViewModel(post)), self.user).get(post.getIDForInteractions()); return new WebDeltaResponse(resp) .setContent("likeCounterPost"+post.id, String.valueOf(interactions.likeCount)) .setAttribute("likeButtonPost"+post.id, "href", post.getInternalURL()+"/unlike?csrf="+requireSession(req).csrfToken); @@ -472,7 +483,7 @@ public static Object unlike(Request req, Response resp, Account self, Applicatio String back=Utils.back(req); ctx.getUserInteractionsController().setObjectLiked(post, false, self.user); if(isAjax(req)){ - UserInteractions interactions=ctx.getWallController().getUserInteractions(List.of(new PostViewModel(post)), self.user).get(post.id); + UserInteractions interactions=ctx.getWallController().getUserInteractions(List.of(new PostViewModel(post)), self.user).get(post.getIDForInteractions()); WebDeltaResponse b=new WebDeltaResponse(resp) .setContent("likeCounterPost"+post.id, String.valueOf(interactions.likeCount)) .setAttribute("likeButtonPost"+post.id, "href", post.getInternalURL()+"/like?csrf="+requireSession(req).csrfToken); @@ -581,6 +592,7 @@ private static Object wall(Request req, Response resp, Actor owner, boolean ownO int offset=offset(req); PaginatedList wall=PostViewModel.wrap(ctx.getWallController().getWallPosts(self!=null ? self.user : null, owner, ownOnly, offset, 20)); + ctx.getWallController().populateReposts(self!=null ? self.user : null, wall.list, 2); if(req.attribute("mobile")==null){ ctx.getWallController().populateCommentPreviews(self!=null ? self.user : null, wall.list); } @@ -618,6 +630,7 @@ public static Object wallToWall(Request req, Response resp){ int offset=offset(req); PaginatedList wall=PostViewModel.wrap(ctx.getWallController().getWallToWallPosts(self!=null ? self.user : null, user, otherUser, offset, 20)); + ctx.getWallController().populateReposts(self!=null ? self.user : null, wall.list, 2); if(req.attribute("mobile")==null){ ctx.getWallController().populateCommentPreviews(self!=null ? self.user : null, wall.list); } diff --git a/src/main/java/smithereen/routes/ProfileRoutes.java b/src/main/java/smithereen/routes/ProfileRoutes.java index 1d1efd65..dafd5458 100644 --- a/src/main/java/smithereen/routes/ProfileRoutes.java +++ b/src/main/java/smithereen/routes/ProfileRoutes.java @@ -76,6 +76,7 @@ public static Object profile(Request req, Response resp){ .with("canMessage", canMessage) .paginate(wall); + ctx.getWallController().populateReposts(self!=null ? self.user : null, wall.list, 2); if(req.attribute("mobile")==null){ ctx.getWallController().populateCommentPreviews(self!=null ? self.user : null, wall.list); } diff --git a/src/main/java/smithereen/storage/DatabaseSchemaUpdater.java b/src/main/java/smithereen/storage/DatabaseSchemaUpdater.java index 4a2d7588..832c22b7 100644 --- a/src/main/java/smithereen/storage/DatabaseSchemaUpdater.java +++ b/src/main/java/smithereen/storage/DatabaseSchemaUpdater.java @@ -37,7 +37,7 @@ import smithereen.util.XTEA; public class DatabaseSchemaUpdater{ - public static final int SCHEMA_VERSION=43; + public static final int SCHEMA_VERSION=44; private static final Logger LOG=LoggerFactory.getLogger(DatabaseSchemaUpdater.class); public static void maybeUpdate() throws SQLException{ @@ -658,6 +658,9 @@ PRIMARY KEY (`id`) CREATE FUNCTION `bin_prefix`(p VARBINARY(1024)) RETURNS varbinary(2048) DETERMINISTIC RETURN CONCAT(REPLACE(REPLACE(REPLACE(p, '\\\\', '\\\\\\\\'), '%', '\\\\%'), '_', '\\\\_'), '%');"""); } + case 44 -> { + conn.createStatement().execute("ALTER TABLE `wall_posts` ADD `flags` bigint unsigned NOT NULL DEFAULT 0, DROP FOREIGN KEY wall_posts_ibfk_2"); + } } } diff --git a/src/main/java/smithereen/storage/GroupStorage.java b/src/main/java/smithereen/storage/GroupStorage.java index c6195f0a..47e49e5b 100644 --- a/src/main/java/smithereen/storage/GroupStorage.java +++ b/src/main/java/smithereen/storage/GroupStorage.java @@ -150,7 +150,7 @@ public static synchronized void putOrUpdateForeignGroup(ForeignGroup group) thro .value("event_start_time", group.eventStartTime) .value("event_end_time", group.eventEndTime) .value("type", group.type) - .value("flags", Utils.serializeEnumSet(group.capabilities, ForeignGroup.Capability.class)) + .value("flags", Utils.serializeEnumSet(group.capabilities)) .value("access_type", group.accessType) .value("endpoints", group.serializeEndpoints()) .value("about", group.summary) diff --git a/src/main/java/smithereen/storage/PostStorage.java b/src/main/java/smithereen/storage/PostStorage.java index bcbf7f74..d06d6748 100644 --- a/src/main/java/smithereen/storage/PostStorage.java +++ b/src/main/java/smithereen/storage/PostStorage.java @@ -169,6 +169,8 @@ public static synchronized void putForeignWallPost(Post post) throws SQLExceptio .value("ap_replies", Objects.toString(post.activityPubReplies, null)) .value("poll_id", post.poll!=null ? post.poll.id : null) .value("privacy", post.privacy) + .value("repost_of", post.repostOf) + .value("flags", Utils.serializeEnumSet(post.flags)) .createStatement(Statement.RETURN_GENERATED_KEYS); }else{ if(post.poll!=null && Objects.equals(post.poll, existing.poll)){ // poll is unchanged, update vote counts @@ -471,6 +473,26 @@ public static void deletePost(int id) throws SQLException{ SQLQueryBuilder.prepareStatement(conn, "DELETE FROM polls WHERE id=?", post.poll.id).execute(); } + // Delete Mastodon-style reposts as well + Set reposts=new SQLQueryBuilder(conn) + .selectFrom("wall_posts") + .columns("id") + .where("repost_of=? AND (flags & 1)=1", id) + .executeAndGetIntStream() + .boxed() + .collect(Collectors.toSet()); + if(!reposts.isEmpty()){ + new SQLQueryBuilder(conn) + .deleteFrom("newsfeed") + .whereIn("object_id", reposts) + .andWhere("`type`=?", NewsfeedEntry.Type.POST) + .executeNoResult(); + new SQLQueryBuilder(conn) + .deleteFrom("wall_posts") + .whereIn("id", reposts) + .executeNoResult(); + } + if(needFullyDelete){ stmt=conn.prepareStatement("DELETE FROM `wall_posts` WHERE `id`=?"); stmt.setInt(1, id); diff --git a/src/main/java/smithereen/templates/PictureForAvatarFilter.java b/src/main/java/smithereen/templates/PictureForAvatarFilter.java index 1dc3d5c0..26190033 100644 --- a/src/main/java/smithereen/templates/PictureForAvatarFilter.java +++ b/src/main/java/smithereen/templates/PictureForAvatarFilter.java @@ -21,15 +21,12 @@ public class PictureForAvatarFilter implements Filter{ public Object apply(Object input, Map args, PebbleTemplate self, EvaluationContext context, int lineNumber) throws PebbleException{ SizedImage image; String additionalClasses=""; - if(input instanceof Actor){ - Actor actor=(Actor) input; + if(input instanceof Actor actor){ image=actor.getAvatar(); - if(actor instanceof User && ((User)actor).gender==User.Gender.FEMALE) - additionalClasses=" female"; - else if(actor instanceof Group) + if(actor instanceof Group) additionalClasses=" group"; }else{ - return ""; + image=null; } String typeStr=(String) args.get("type"); diff --git a/src/main/resources/langs/en/wall.json b/src/main/resources/langs/en/wall.json index b7155bfe..3ec4b9f7 100644 --- a/src/main/resources/langs/en/wall.json +++ b/src/main/resources/langs/en/wall.json @@ -63,5 +63,8 @@ "post_visible_to_followers": "This post is only visible to {name}''s followers", "post_visible_to_followers_mentioned": "This post is only visible to {name}''s followers and mentioned people", "post_visible_to_friends": "This post is only visible to {name}''s friends", - "expand_all_cws": "Expand all CWs" + "expand_all_cws": "Expand all CWs", + "comment_repost_title": "{time} on post {postSnippet}", + "comment_deleted_repost_title": "{time} on a deleted post", + "commenting_on_repost_hint": "Your comment will appear on {name}''s original post." } \ No newline at end of file diff --git a/src/main/resources/langs/ru/wall.json b/src/main/resources/langs/ru/wall.json index 2914c4d6..2d227b5b 100644 --- a/src/main/resources/langs/ru/wall.json +++ b/src/main/resources/langs/ru/wall.json @@ -63,5 +63,8 @@ "post_visible_to_followers": "Эта запись видна только подписчикам {name, inflect, genitive}", "post_visible_to_followers_mentioned": "Эта запись видна только подписчикам {name, inflect, genitive} и упомянутым", "post_visible_to_friends": "Эта запись видна только друзьям {name, inflect, genitive}", - "expand_all_cws": "Раскрыть все спойлеры" + "expand_all_cws": "Раскрыть все спойлеры", + "comment_repost_title": "{time} к записи {postSnippet}", + "comment_deleted_repost_title": "{time} к удалённой записи", + "commenting_on_repost_hint": "Вы комментируете изначальную запись {name, inflect, genitive}." } \ No newline at end of file diff --git a/src/main/resources/templates/desktop/wall_post.twig b/src/main/resources/templates/desktop/wall_post.twig index fa8d43b2..405ad05b 100644 --- a/src/main/resources/templates/desktop/wall_post.twig +++ b/src/main/resources/templates/desktop/wall_post.twig @@ -2,7 +2,7 @@ {# @pebvariable name="realPost" type="smithereen.model.Post" #} {% set realPost=post.post %} {% if postInteractions is not null %} -{% set interactions=postInteractions[realPost.id] %} +{% set interactions=postInteractions[realPost.getIDForInteractions()] %} {% endif %} @@ -16,25 +16,8 @@ {%- endif %}
{% block postInner %} - {% if realPost.hasContentWarning %} - -
- - {% endif %} - {% if standalone %} -
{{ realPost.text | postprocessHTML }}
- {% else %} -
{{ realPost.text | postprocessHTML | truncateText }}
- {% endif %} - {% if realPost.poll is not null %} - {% include "poll" with {'poll': realPost.poll, 'interactions': interactions} %} - {% endif %} - {% if realPost.attachments is not empty %} - {{ renderAttachments(realPost.processedAttachments, realPost.ownerID>0 ? users[realPost.ownerID] : groups[-realPost.ownerID]) }} - {% endif %} - {% if realPost.hasContentWarning %} -
- {% endif %} + {% include "wall_post_inner" with {'post': post, 'repostDepth': 0} %} + {% set realPost=post.post %}{# because included template overwrote it #} {% if realPost.federationState=='REJECTED' %}
{{ L('wall_post_rejected') }}
{% endif %} @@ -81,15 +64,16 @@ {% endif %}
- {%for reply in post.repliesObjects%} + {%- for reply in post.repliesObjects %} {% include "wall_reply" with {'post': reply, 'preview': true, 'replyFormID': "wallPostForm_commentReplyPost#{realPost.id}", 'topLevel': post} %} {% set realPost=post.post %}{# the included template overwrites this variable #} - {%endfor%} + {% endfor -%}
- {%set interactions=postInteractions[realPost.id]%} + {%set interactions=postInteractions[realPost.getIDForInteractions()]%} {% if currentUser is not null and post.canComment %} - {% include "wall_post_form" with {'id': "commentReplyPost#{realPost.id}", 'replyTo': realPost, 'hidden': true} %} - {% include "wall_post_form" with {'id': "commentPost#{realPost.id}", 'replyTo': realPost, 'hidden': interactions.commentCount==0} %} + {% if post.repost is not null and post.repost.post is not null %}{% set origAuthor=users[post.repost.post.post.authorID] %}{% endif %} + {% include "wall_post_form" with {'id': "commentPost#{realPost.id}", 'replyTo': realPost, 'replyToModel': post, 'hidden': interactions.commentCount==0, 'originalPostAuthor': origAuthor} %} + {% include "wall_post_form" with {'id': "commentReplyPost#{realPost.id}", 'replyTo': realPost, 'hidden': true, 'originalPostAuthor': origAuthor} %} {% endif %}
{% endif %} \ No newline at end of file diff --git a/src/main/resources/templates/desktop/wall_post_form.twig b/src/main/resources/templates/desktop/wall_post_form.twig index 5e675b44..5154bd07 100644 --- a/src/main/resources/templates/desktop/wall_post_form.twig +++ b/src/main/resources/templates/desktop/wall_post_form.twig @@ -52,15 +52,19 @@ {% if poll is not null %}{% include 'poll_form' with {'poll': poll} %}{% endif %} -
- -
+
+
{% if isEditing %} {% else %} {% endif %} -
+ +
+ {% if originalPostAuthor is not null %}{{ L('commenting_on_repost_hint', {'name': originalPostAuthor | name('firstAndGender')}) }}{% endif %} +
+
{{ L('attach') }}
-
+ +