Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export default defineConfig({
"**/channel-controls-screenshots.spec.ts",
"**/team-management-screenshots.spec.ts",
"**/file-attachment.spec.ts",
"**/video-attachment.spec.ts",
"**/mentions.spec.ts",
"**/relay-reconnect.spec.ts",
"**/workflows.spec.ts",
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ hex = "0.4"
tokio = { version = "1", features = ["fs", "sync", "rt", "macros", "time"] }
tokio-tungstenite = { version = "0.29", features = ["rustls-tls-webpki-roots"] }
tokio-util = { version = "0.7", features = ["rt"] }
bytes = "1"
futures-util = "0.3"
opus = "0.3"
neteq = { version = "0.8", default-features = false }
Expand Down
49 changes: 39 additions & 10 deletions desktop/src-tauri/src/commands/media.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ async fn do_upload(
body: Vec<u8>,
mime: &str,
state: &State<'_, AppState>,
progress: Option<(tauri::AppHandle, String)>,
) -> Result<BlobDescriptor, String> {
let sha256 = hex::encode(Sha256::digest(&body));

Expand Down Expand Up @@ -216,11 +217,36 @@ async fn do_upload(
.header("Content-Type", mime)
.header("X-SHA-256", &sha256);

let resp = req
.body(body)
.send()
.await
.map_err(|e| format!("upload failed: {e}"))?;
// With a progress channel, stream the body in chunks and emit a
// `media-upload-progress` event as each chunk is handed to the socket,
// so the renderer can draw a determinate progress bar.
let resp = if let Some((app, progress_id)) = progress {
use tauri::Emitter;
let total = body.len() as u64;
// Ref-counted slices of one buffer — no second copy of the payload.
let body = bytes::Bytes::from(body);
let chunk_size = 64 * 1024;
let chunk_count = body.len().div_ceil(chunk_size);
let mut sent: u64 = 0;
let stream = futures_util::stream::iter((0..chunk_count).map(move |i| {
let start = i * chunk_size;
let end = usize::min(start + chunk_size, body.len());
let chunk = body.slice(start..end);
sent += chunk.len() as u64;
let _ = app.emit(
"media-upload-progress",
serde_json::json!({ "id": progress_id, "sent": sent, "total": total }),
);
Ok::<bytes::Bytes, std::io::Error>(chunk)
}));
req.header(reqwest::header::CONTENT_LENGTH, total)
.body(reqwest::Body::wrap_stream(stream))
.send()
.await
} else {
req.body(body).send().await
}
.map_err(|e| format!("upload failed: {e}"))?;

if !resp.status().is_success() {
let status = resp.status();
Expand Down Expand Up @@ -267,7 +293,7 @@ pub async fn upload_media(
}

let mime = detect_and_validate_mime(&body)?;
do_upload(body, &mime, &state).await
do_upload(body, &mime, &state, None).await
}

/// Read a picked path through the TOCTOU-safe pipeline (fd pin → sniff →
Expand Down Expand Up @@ -316,10 +342,10 @@ async fn process_picked_path(

// Upload video first, then poster (best-effort). If poster upload fails,
// the video descriptor is returned without an image field.
let mut descriptor = do_upload(body, &mime, state).await?;
let mut descriptor = do_upload(body, &mime, state, None).await?;

if let Some(poster) = poster_bytes {
match do_upload(poster, "image/jpeg", state).await {
match do_upload(poster, "image/jpeg", state, None).await {
Ok(poster_desc) => descriptor.image = Some(poster_desc.url),
Err(e) => eprintln!("sprout-desktop: poster upload failed (non-fatal): {e}"),
}
Expand Down Expand Up @@ -387,6 +413,8 @@ pub async fn pick_and_upload_media(
pub async fn upload_media_bytes(
data: Vec<u8>,
filename: Option<String>,
progress_id: Option<String>,
app: tauri::AppHandle,
state: State<'_, AppState>,
) -> Result<BlobDescriptor, String> {
if data.is_empty() {
Expand Down Expand Up @@ -417,10 +445,11 @@ pub async fn upload_media_bytes(
let mime = detect_and_validate_mime(&body)?;

// Upload video first, then poster (best-effort).
let mut descriptor = do_upload(body, &mime, &state).await?;
let progress = progress_id.map(|id| (app, id));
let mut descriptor = do_upload(body, &mime, &state, progress).await?;

if let Some(poster) = poster_bytes {
match do_upload(poster, "image/jpeg", &state).await {
match do_upload(poster, "image/jpeg", &state, None).await {
Ok(poster_desc) => descriptor.image = Some(poster_desc.url),
Err(e) => eprintln!("sprout-desktop: poster upload failed (non-fatal): {e}"),
}
Expand Down
47 changes: 45 additions & 2 deletions desktop/src/features/channels/ui/ChannelPane.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import * as React from "react";
import { Hash, LogIn } from "lucide-react";

import { useMediaUpload } from "@/features/messages/lib/useMediaUpload";
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
import { DropZoneOverlay } from "@/features/messages/ui/ComposerAttachments";
import { MessageThreadPanel } from "@/features/messages/ui/MessageThreadPanel";
import { MessageTimeline } from "@/features/messages/ui/MessageTimeline";
import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown";
Expand Down Expand Up @@ -71,6 +73,13 @@ type ChannelPaneProps = {
mentionPubkeys: string[],
mediaTags?: string[][],
) => Promise<void>;
onSendVideoReviewComment?: (
message: TimelineMessage,
content: string,
mentionPubkeys: string[],
mediaTags?: string[][],
parentEventId?: string,
) => Promise<void>;
onSendThreadReply: (
content: string,
mentionPubkeys: string[],
Expand Down Expand Up @@ -148,6 +157,7 @@ export const ChannelPane = React.memo(function ChannelPane({
onResetThreadPanelWidth,
onSelectThreadReplyTarget,
onSendMessage,
onSendVideoReviewComment,
onSendThreadReply,
onThreadScrollTargetResolved,
onThreadPanelResizeStart,
Expand All @@ -171,6 +181,7 @@ export const ChannelPane = React.memo(function ChannelPane({
}: ChannelPaneProps) {
const timelineScrollRef = React.useRef<HTMLDivElement>(null);
const composerWrapperRef = React.useRef<HTMLDivElement>(null);
const mainComposerMedia = useMediaUpload();
const isNonMemberView =
activeChannel !== null &&
!activeChannel.isMember &&
Expand Down Expand Up @@ -243,6 +254,8 @@ export const ChannelPane = React.memo(function ChannelPane({
activeChannel.archivedAt !== null ||
activeChannel.channelType === "forum" ||
isSending;
const canDropInMainColumn =
hasMainComposerOverlay && !isComposerDisabled && !isSinglePanelView;
const hasTypingActivity = typingPubkeys.length > 0;
const composerBotTypingPubkeys = React.useMemo(() => {
const pubkeys: string[] = [];
Expand Down Expand Up @@ -335,7 +348,27 @@ export const ChannelPane = React.memo(function ChannelPane({
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-row overflow-hidden">
{!isSinglePanelView ? (
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<section
aria-label="Channel messages and composer"
className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"
data-testid="channel-drop-zone"
onDragEnter={
canDropInMainColumn ? mainComposerMedia.handleDragEnter : undefined
}
onDragLeave={
canDropInMainColumn ? mainComposerMedia.handleDragLeave : undefined
}
onDragOver={
canDropInMainColumn ? mainComposerMedia.handleDragOver : undefined
}
onDrop={
canDropInMainColumn
? (event) => {
void mainComposerMedia.handleDrop(event);
}
: undefined
}
>
{channelFind.isOpen ? (
<ChannelFindBar
matchCount={channelFind.matchCount}
Expand Down Expand Up @@ -380,6 +413,12 @@ export const ChannelPane = React.memo(function ChannelPane({
onEdit={onEdit}
onMarkUnread={onMarkUnread}
onReply={activeChannel?.archivedAt ? undefined : onOpenThread}
channelName={activeChannel?.name}
channelType={activeChannel?.channelType ?? null}
isSendingVideoReviewComment={isSending}
onSendVideoReviewComment={
activeChannel?.archivedAt ? undefined : onSendVideoReviewComment
}
onTargetReached={onTargetReached}
onToggleReaction={onToggleReaction}
searchActiveMessageId={channelFind.activeMatch?.messageId ?? null}
Expand Down Expand Up @@ -426,6 +465,7 @@ export const ChannelPane = React.memo(function ChannelPane({
disabled={isComposerDisabled}
editTarget={mainEditTarget}
isSending={isSending}
mediaController={mainComposerMedia}
onCancelEdit={onCancelEdit}
onEditLastOwnMessage={handleEditLastOwnMainMessage}
onEditSave={onEditSave}
Expand Down Expand Up @@ -471,7 +511,10 @@ export const ChannelPane = React.memo(function ChannelPane({
</div>
</div>
)}
</div>
{canDropInMainColumn && mainComposerMedia.isDragOver ? (
<DropZoneOverlay className="z-30 rounded-none" />
) : null}
</section>
) : null}

{threadHeadMessage ? (
Expand Down
22 changes: 22 additions & 0 deletions desktop/src/features/channels/ui/ChannelScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,27 @@ export function ChannelScreen({
: undefined,
[activeChannel, handleToggleReaction],
);
const handleSendVideoReviewComment = React.useCallback(
async (
message: { id: string },
content: string,
mentionPubkeys: string[],
mediaTags?: string[][],
parentEventId?: string,
) => {
await sendMessageMutation.mutateAsync({
content,
mediaTags,
mentionPubkeys,
parentEventId: parentEventId ?? message.id,
});
},
[sendMessageMutation],
);
const effectiveSendVideoReviewComment =
activeChannel && !activeChannel.archivedAt && activeChannel.isMember
? handleSendVideoReviewComment
: undefined;
const handleMarkUnread = React.useCallback(() => {
if (!activeChannelId) return;
markChannelUnread(activeChannelId);
Expand Down Expand Up @@ -599,6 +620,7 @@ export function ChannelScreen({
onOpenThread={handleOpenThreadAndCloseAgentSession}
onSelectThreadReplyTarget={handleSelectThreadReplyTarget}
onSendMessage={handleSendMessage}
onSendVideoReviewComment={effectiveSendVideoReviewComment}
onSendThreadReply={handleSendThreadReply}
onThreadScrollTargetResolved={
handleThreadScrollTargetResolved
Expand Down
4 changes: 4 additions & 0 deletions desktop/src/features/forum/ui/ForumComposerMediaStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import { ComposerAttachments } from "@/features/messages/ui/ComposerAttachments"
type ComposerMedia = Pick<
ReturnType<typeof useMediaUpload>,
| "isUploading"
| "cancelUpload"
| "pendingImeta"
| "removeAttachment"
| "setUploadState"
| "uploadState"
| "uploadingCount"
| "uploadingPreviews"
>;

type ForumComposerMediaStatusProps = {
Expand Down Expand Up @@ -38,8 +40,10 @@ export function ForumComposerMediaStatus({
<ComposerAttachments
attachments={media.pendingImeta}
isUploading={media.isUploading}
onCancelUpload={media.cancelUpload}
onRemove={media.removeAttachment}
uploadingCount={media.uploadingCount}
uploadingPreviews={media.uploadingPreviews}
/>
</div>
)}
Expand Down
15 changes: 13 additions & 2 deletions desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,9 @@ test("formatImetaMediaLine: image mime → ![image] line", () => {
);
});

test("buildImetaTags omits image filenames from imeta", () => {
test("buildImetaTags keeps media filenames in imeta", () => {
// Filenames are included for every MIME type — the video review dialog
// and file cards use them as display titles.
assert.deepEqual(
buildImetaTags([
{
Expand All @@ -93,7 +95,16 @@ test("buildImetaTags omits image filenames from imeta", () => {
filename: "Party Parrot.png",
},
]),
[["imeta", "url https://b/a.png", "m image/png", "x abc", "size 10"]],
[
[
"imeta",
"url https://b/a.png",
"m image/png",
"x abc",
"size 10",
"filename Party Parrot.png",
],
],
);
});

Expand Down
6 changes: 1 addition & 5 deletions desktop/src/features/messages/lib/imetaMediaMarkdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,7 @@ export function buildImetaTags(
...(d.thumb ? [`thumb ${d.thumb}`] : []),
...(d.duration != null ? [`duration ${d.duration}`] : []),
...(d.image ? [`image ${d.image}`] : []),
...(!d.type.startsWith("image/") &&
!d.type.startsWith("video/") &&
d.filename
? [`filename ${d.filename}`]
: []),
...(d.filename ? [`filename ${d.filename}`] : []),
]);
}

Expand Down
Loading