update fork #128

Manually merged
tristan merged 181 commits from mirror/monocles_chat_clean:master into master 2026-01-23 14:02:38 +01:00
8 changed files with 272 additions and 79 deletions
Showing only changes of commit b9f87e830a - Show all commits

Add support for attachments in posts

Arne 2026-01-09 17:37:11 +01:00

View file

@ -16,14 +16,16 @@ public class Post {
private final Jid author;
private final Date published;
private final String commentsNode;
private final String attachmentUrl;
public Post(String id, String title, String content, Jid author, Date published, String commentsNode) {
public Post(String id, String title, String content, Jid author, Date published, String commentsNode, String attachmentUrl) {
this.id = id;
this.title = title;
this.content = content;
this.author = author;
this.published = published;
this.commentsNode = commentsNode;
this.attachmentUrl = attachmentUrl;
}
public static Post fromElement(Element entry) {
@ -51,12 +53,21 @@ public class Post {
// ignore
}
}
Element link = entry.findChild("link", Namespace.ATOM);
String commentsNode = null;
if (link != null && "replies".equals(link.getAttribute("rel"))) {
commentsNode = link.getAttribute("href");
String attachmentUrl = null;
for (Element link : entry.getChildren()) {
if ("link".equals(link.getName()) && Namespace.ATOM.equals(link.getNamespace())) {
String rel = link.getAttribute("rel");
if ("replies".equals(rel)) {
commentsNode = link.getAttribute("href");
} else if ("enclosure".equals(rel)) {
attachmentUrl = link.getAttribute("href");
}
}
}
return new Post(id, title, content, author, published, commentsNode);
return new Post(id, title, content, author, published, commentsNode, attachmentUrl);
}
public String getId() {
@ -82,4 +93,8 @@ public class Post {
public String getCommentsNode() {
return commentsNode;
}
}
public String getAttachmentUrl() {
return attachmentUrl;
}
}

View file

@ -830,7 +830,7 @@ public class IqGenerator extends AbstractGenerator {
return iq;
}
public Iq publishPost(final Account account, final String node, final String title, final String content, final String inReplyToId, final String postId) {
public Iq publishPost(final Account account, final String node, final String title, final String content, final String inReplyToId, final String postId, final String attachmentUrl, final String attachmentType) {
final Iq iq = new Iq(Iq.Type.SET);
final boolean isComment = inReplyToId != null;
final String fullNode = isComment ? "urn:xmpp:microblog:0:comments/" + inReplyToId : node;
@ -839,18 +839,31 @@ public class IqGenerator extends AbstractGenerator {
final Element publish = pubsub.addChild("publish");
publish.setAttribute("node", fullNode);
final Element item = publish.addChild("item");
final String id = postId != null ? postId : "tag:" + account.getServer() + "," + AbstractGenerator.getTimestamp(System.currentTimeMillis()) + ":" + UUID.randomUUID().toString(); item.setAttribute("id", id);
final String id = postId != null ? postId : "tag:" + account.getServer() + "," + AbstractGenerator.getTimestamp(System.currentTimeMillis()) + ":" + UUID.randomUUID().toString();
if (!isComment) {
item.setAttribute("id", id);
}
final Element entry = item.addChild("entry", Namespace.ATOM);
if (!isComment) {
entry.addChild("link")
.setAttribute("rel", "replies")
.setAttribute("type", "application/atom+xml")
.setAttribute("href", "xmpp:" + account.getJid().asBareJid() + "?;node=urn:xmpp:microblog:0:comments/" + id);
}
if (inReplyToId != null) {
entry.setAttribute("xmlns:thr", "http://purl.org/syndication/thread/1.0");
entry.addChild("thr:in-reply-to").setAttribute("ref", inReplyToId);
}
if (attachmentUrl != null && attachmentType != null) {
entry.addChild("link")
.setAttribute("rel", "enclosure")
.setAttribute("href", attachmentUrl)
.setAttribute("type", attachmentType);
}
entry.addChild("title").setContent(title);
entry.addChild("content").setContent(content);
final Element author = entry.addChild("author");

View file

@ -8174,30 +8174,25 @@ public class XmppConnectionService extends Service {
void onPubsubItemsFetchFailed();
}
public void publishPost(final String node, final String title, final String content, final String inReplyToId, final String postId, final OnPostPublished callback) {
Account account = null;
for (Account acc : getAccounts()) {
if (acc.isOnlineAndConnected()) {
account = acc;
break;
}
}
public void publishPost(final String node, final String title, final String content, final String inReplyToId, final String postId, final String attachmentUrl, final String attachmentType, final OnPostPublished callback) {
Account account = AccountUtils.getFirstEnabled(getAccounts());
if (account == null) {
if (callback != null) {
callback.onPostPublishFailed();
}
return;
}
final Iq request = getIqGenerator().publishPost(account, node, title, content, inReplyToId, postId);
sendIqPacket(account, request, response -> { if (response.getType() == Iq.Type.RESULT) {
if (callback != null) {
callback.onPostPublished();
final Iq request = getIqGenerator().publishPost(account, node, title, content, inReplyToId, postId, attachmentUrl, attachmentType);
sendIqPacket(account, request, response -> {
if (response.getType() == Iq.Type.RESULT) {
if (callback != null) {
callback.onPostPublished();
}
} else {
if (callback != null) {
callback.onPostPublishFailed();
}
}
} else {
if (callback != null) {
callback.onPostPublishFailed();
}
}
});
}

View file

@ -1,16 +1,26 @@
package eu.siacs.conversations.ui;
import android.Manifest;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Bundle;
import android.provider.MediaStore;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.WindowManager;
import android.widget.Toast;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.core.content.ContextCompat;
import androidx.databinding.DataBindingUtil;
import eu.siacs.conversations.R;
import eu.siacs.conversations.databinding.ActivityCreatePostBinding;
import eu.siacs.conversations.services.XmppConnectionService;
import eu.siacs.conversations.utils.FileUtils;
public class CreatePostActivity extends XmppActivity {
@ -18,6 +28,38 @@ public class CreatePostActivity extends XmppActivity {
private String inReplyToId;
private String inReplyToNode;
private String postId;
private Uri attachmentUri;
private Uri mCameraUri;
private final ActivityResultLauncher<String> requestPermissionLauncher =
registerForActivityResult(new ActivityResultContracts.RequestPermission(), isGranted -> {
if (isGranted) {
openCamera();
} else {
Toast.makeText(this, R.string.no_camera_permission, Toast.LENGTH_SHORT).show();
}
});
private final ActivityResultLauncher<Intent> takePictureLauncher =
registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> {
if (result.getResultCode() == RESULT_OK) {
attachmentUri = mCameraUri;
binding.attachmentPreview.setImageURI(attachmentUri);
binding.attachmentPreview.setVisibility(View.VISIBLE);
}
});
private final ActivityResultLauncher<String> attachFileLauncher = registerForActivityResult(
new ActivityResultContracts.GetContent(),
uri -> {
if (uri != null) {
this.attachmentUri = uri;
binding.attachmentPreview.setImageURI(attachmentUri);
binding.attachmentPreview.setVisibility(View.VISIBLE);
}
}
);
@Override
protected void onCreate(Bundle savedInstanceState) {
@ -39,6 +81,25 @@ public class CreatePostActivity extends XmppActivity {
}
binding.publishButton.setOnClickListener(v -> publishPost());
binding.attachFileButton.setOnClickListener(v -> attachFileLauncher.launch("*/*"));
binding.attachImageButton.setOnClickListener(v -> {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
openCamera();
} else {
requestPermissionLauncher.launch(Manifest.permission.CAMERA);
}
});
}
private void openCamera() {
if (xmppConnectionService == null) {
Toast.makeText(this, R.string.not_connected_try_again, Toast.LENGTH_SHORT).show();
return;
}
mCameraUri = xmppConnectionService.getFileBackend().getTakePhotoUri();
Intent takePictureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, mCameraUri);
takePictureLauncher.launch(takePictureIntent);
}
@Override
@ -55,27 +116,53 @@ public class CreatePostActivity extends XmppActivity {
String title = binding.postTitleEditText.getText().toString();
String content = binding.postContentEditText.getText().toString();
if (title.isEmpty() && content.isEmpty()) {
Toast.makeText(this, R.string.title_or_content_are_required, Toast.LENGTH_SHORT).show();
if (title.isEmpty() && content.isEmpty() && attachmentUri == null) {
Toast.makeText(this, R.string.title_or_content_or_attachment_required, Toast.LENGTH_SHORT).show();
return;
}if (xmppConnectionService != null) {
xmppConnectionService.publishPost("urn:xmpp:microblog:0", title, content, inReplyToId, postId, new XmppConnectionService.OnPostPublished() {
@Override
public void onPostPublished() {
runOnUiThread(() -> {
Toast.makeText(CreatePostActivity.this, R.string.post_published, Toast.LENGTH_SHORT).show();
finish();
});
}
@Override
public void onPostPublishFailed() {
runOnUiThread(() -> {
Toast.makeText(CreatePostActivity.this, R.string.error_publish_post, Toast.LENGTH_SHORT).show();
});
}
});
}
if (xmppConnectionService != null) {
if (attachmentUri != null) {
final String mimeType = getContentResolver().getType(attachmentUri);
xmppConnectionService.uploadFileForUrl(xmppConnectionService.getAccounts().get(0), attachmentUri, mimeType, new UiCallback<String>() {
@Override
public void success(String url) {
publish(title, content, url, mimeType);
}
@Override
public void error(int errorCode, String object) {
runOnUiThread(() -> Toast.makeText(CreatePostActivity.this, errorCode, Toast.LENGTH_SHORT).show());
}
@Override
public void userInputRequired(android.app.PendingIntent pi, String object) {
// Not handled
}
});
} else {
publish(title, content, null, null);
}
}
}
private void publish(String title, String content, String attachmentUrl, String attachmentType) {
xmppConnectionService.publishPost("urn:xmpp:microblog:0", title, content, inReplyToId, postId, attachmentUrl, attachmentType, new XmppConnectionService.OnPostPublished() {
@Override
public void onPostPublished() {
runOnUiThread(() -> {
Toast.makeText(CreatePostActivity.this, R.string.post_published, Toast.LENGTH_SHORT).show();
finish();
});
}
@Override
public void onPostPublishFailed() {
runOnUiThread(() -> {
Toast.makeText(CreatePostActivity.this, R.string.error_publish_post, Toast.LENGTH_SHORT).show();
});
}
});
}
@Override
@ -91,4 +178,4 @@ public class CreatePostActivity extends XmppActivity {
}
return super.onOptionsItemSelected(item);
}
}
}

View file

@ -1,19 +1,27 @@
package eu.siacs.conversations.ui.adapter;
import android.app.AlertDialog;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.bumptech.glide.Glide;
import java.io.File;
import java.text.DateFormat;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import eu.siacs.conversations.R;
import eu.siacs.conversations.databinding.ItemPostBinding;
import eu.siacs.conversations.entities.Account;
@ -64,10 +72,17 @@ public class PostsAdapter extends RecyclerView.Adapter<PostsAdapter.PostViewHold
}
void bind(Post post) {
final boolean isExpanded = expandedPosts.contains(post);
final boolean isExpanded = expandedPosts.contains(post);final boolean hasAttachment = post.getAttachmentUrl() != null;
binding.postContentSummary.setVisibility(isExpanded ? View.GONE : View.VISIBLE);
binding.postContentFull.setVisibility(isExpanded ? View.VISIBLE : View.GONE);
binding.postActions.setVisibility(isExpanded ? View.VISIBLE : View.GONE);
binding.attachmentHint.setVisibility(hasAttachment && !isExpanded ? View.VISIBLE : View.GONE);
binding.postImage.setVisibility(hasAttachment && isExpanded ? View.VISIBLE : View.GONE);
if (hasAttachment && isExpanded) {
Glide.with(mActivity).load(post.getAttachmentUrl()).into(binding.postImage);
}
binding.postContentSummary.setText(post.getContent());
binding.postContentFull.setText(post.getContent());
@ -110,6 +125,7 @@ public class PostsAdapter extends RecyclerView.Adapter<PostsAdapter.PostViewHold
mActivity.startActivity(Intent.createChooser(intent, mActivity.getString(R.string.share_post_with)));
});
if (post.getAuthor() != null && mActivity.xmppConnectionService != null) {
Account account = AccountUtils.getFirstEnabled(mActivity.xmppConnectionService.getAccounts());
if (account != null && post.getAuthor().asBareJid().equals(account.getJid().asBareJid())) {
@ -123,31 +139,32 @@ public class PostsAdapter extends RecyclerView.Adapter<PostsAdapter.PostViewHold
mActivity.startActivity(intent);
});
binding.deleteButton.setOnClickListener(v -> {
new MaterialAlertDialogBuilder(mActivity)
.setTitle(R.string.retract_post)
.setMessage(R.string.retract_post_confirm)
.setPositiveButton(R.string.retract, (dialog, which) -> {
mActivity.xmppConnectionService.retractPost("urn:xmpp:microblog:0", post.getId(), new XmppConnectionService.OnPostRetracted() {
@Override
public void onPostRetracted() {
mActivity.runOnUiThread(() -> {
int pos = getAdapterPosition();
if (pos != RecyclerView.NO_POSITION) {
posts.remove(pos);
notifyItemRemoved(pos);
}
});
}
new AlertDialog.Builder(mActivity)
.setTitle(R.string.retract_post)
.setMessage(R.string.retract_post_confirm)
.setPositiveButton(R.string.retract, (dialog, which) -> {
mActivity.xmppConnectionService.retractPost("urn:xmpp:microblog:0", post.getId(), new XmppConnectionService.OnPostRetracted() {
@Override
public void onPostRetracted() {
mActivity.runOnUiThread(() -> {
int pos = getAdapterPosition();
if(pos != RecyclerView.NO_POSITION) {
posts.remove(pos);
notifyItemRemoved(pos);
}
});
}
@Override
public void onPostRetractionFailed() {
mActivity.runOnUiThread(() -> {
Toast.makeText(mActivity, R.string.error_retract_post, Toast.LENGTH_SHORT).show();
});
}
}); })
.setNegativeButton(R.string.cancel, null)
.show();
@Override
public void onPostRetractionFailed() {
mActivity.runOnUiThread(() -> {
Toast.makeText(mActivity, R.string.error_retract_post, Toast.LENGTH_SHORT).show();
});
}
});
})
.setNegativeButton(R.string.cancel, null)
.show();
});
} else {
binding.editButton.setVisibility(View.GONE);
@ -208,4 +225,4 @@ public class PostsAdapter extends RecyclerView.Adapter<PostsAdapter.PostViewHold
}
}
}
}
}

View file

@ -10,9 +10,9 @@
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/app_bar"
app:layout_constraintBottom_toTopOf="@+id/post_title_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content">
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
@ -22,6 +22,18 @@
</com.google.android.material.appbar.AppBarLayout>
<ImageView
android:id="@+id/attachment_preview"
android:layout_width="100dp"
android:layout_height="100dp"
android:layout_marginTop="8dp"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/app_bar"
tools:src="@tools:sample/avatars"
tools:visibility="visible"/>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/post_title_layout"
android:layout_width="0dp"
@ -29,7 +41,7 @@
android:layout_margin="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/app_bar">
app:layout_constraintTop_toBottomOf="@+id/attachment_preview">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/post_title_edit_text"
@ -44,7 +56,7 @@
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_margin="16dp"
app:layout_constraintBottom_toTopOf="@+id/publish_button"
app:layout_constraintBottom_toTopOf="@+id/attachment_options"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/post_title_layout">
@ -58,6 +70,32 @@
</com.google.android.material.textfield.TextInputLayout>
<LinearLayout
android:id="@+id/attachment_options"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingStart="16dp"
android:paddingEnd="16dp"
app:layout_constraintBottom_toTopOf="@+id/publish_button">
<ImageButton
android:id="@+id/attach_file_button"
style="?android:attr/buttonBarButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ic_attach_file_24dp" />
<ImageButton
android:id="@+id/attach_image_button"
style="?android:attr/buttonBarButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ic_camera_alt_24dp" />
</LinearLayout>
<Button
android:id="@+id/publish_button"
android:layout_width="wrap_content"

View file

@ -75,11 +75,38 @@
android:layout_marginTop="16dp"
android:textAppearance="@style/TextAppearance.AppCompat.Title"
android:textIsSelectable="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintEnd_toStartOf="@+id/attachment_hint"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/post_author_avatar"
tools:text="This is a post title" />
<ImageView
android:id="@+id/attachment_hint"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:src="@drawable/ic_attach_file_white_24dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@+id/post_title"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/post_title"
app:layout_constraintTop_toTopOf="@+id/post_title"
app:tint="?android:attr/textColorSecondary"
tools:visibility="visible" />
<ImageView
android:id="@+id/post_image"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:maxHeight="400dp"
android:layout_marginTop="8dp"
android:adjustViewBounds="true"
android:scaleType="fitCenter"
android:visibility="gone"
app:layout_constraintTop_toBottomOf="@+id/post_title"
tools:src="@tools:sample/backgrounds/scenic"
tools:visibility="visible"/>
<TextView
android:id="@+id/post_content_summary"
android:layout_width="0dp"
@ -89,7 +116,7 @@
android:maxLines="3"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/post_title"
app:layout_constraintTop_toBottomOf="@+id/post_image"
tools:text="This is the content of the post. It can be a long text spanning multiple lines." />
<TextView
@ -102,7 +129,7 @@
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/post_title"
app:layout_constraintTop_toBottomOf="@+id/post_image"
tools:text="This is the content of the post. It can be a long text spanning multiple lines." />
<LinearLayout

View file

@ -1613,6 +1613,7 @@
<string name="retract_post_confirm">Do you really want to retract this post?</string>
<string name="retract">Retract</string>
<string name="error_retract_post">Could not retract post</string>
<string name="title_or_content_or_attachment_required">Title, content or attachment required</string>
<plurals name="publishing_to_x_contacts">
<item quantity="one">Publishing to %d contact</item>
<item quantity="other">Publishing to %d contacts</item>