From a7a430a6270f471ec7c3fa80b84a664db5dead5b Mon Sep 17 00:00:00 2001 From: cyph3rasi Date: Wed, 15 Jul 2026 02:22:12 -0700 Subject: [PATCH] Add right-aligned post sharing with chat, copy-link, and native share actions, plus media downloads and mobile-safe action layout. Hop-State: A_06FPA5BJBEJ930H9MP07GNG Hop-Proposal: R_06FPA5AQ4HY44EBZAGHH5KG Hop-Task: T_06FPA3T7TNGR6E7KE0A2ECR Hop-Attempt: AT_06FPA3T7TN53HDSQHEBWSS8 --- src/app/chat/page.tsx | 18 ++++ src/app/globals.css | 94 +++++++++++++++++- src/components/PostCard.tsx | 187 ++++++++++++++++++++++++++++++++---- 3 files changed, 279 insertions(+), 20 deletions(-) diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index 618d815..446451d 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -41,6 +41,7 @@ export default function ChatPage() { const router = useRouter(); const searchParams = useSearchParams(); const composeHandle = searchParams.get('compose'); + const sharedPostUrl = searchParams.get('share'); // Chat Data State const [conversations, setConversations] = useState([]); @@ -61,6 +62,7 @@ export default function ChatPage() { const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const [isAtBottom, setIsAtBottom] = useState(true); + const appliedSharedPostRef = useRef(null); // ============================================ // HELPER FUNCTIONS (Defined before useEffects) @@ -337,6 +339,16 @@ export default function ChatPage() { } }, [selectedConversation]); + // A post shared from the timeline waits for the user to choose a conversation, + // then appears in the composer so they remain in control of sending it. + useEffect(() => { + if (!selectedConversation || !sharedPostUrl || appliedSharedPostRef.current === sharedPostUrl) return; + + setNewMessage(sharedPostUrl); + appliedSharedPostRef.current = sharedPostUrl; + router.replace('/chat', { scroll: false }); + }, [selectedConversation, sharedPostUrl, router]); + // Auto-scroll to bottom of messages only if user was already at bottom useEffect(() => { if (messagesEndRef.current && isAtBottom) { @@ -555,6 +567,12 @@ export default function ChatPage() { + {sharedPostUrl && ( +
+ Choose a conversation to share this post. +
+ )} +
(null); const domain = useDomain(); const authorHandle = useFormattedHandle(post.author.handle, post.nodeDomain); @@ -425,6 +427,102 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, const postUrl = `/u/${post.author.handle}/posts/${post.id}`; + const getAbsolutePostUrl = () => new URL(postUrl, window.location.origin).toString(); + + const handleSendViaChat = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setShowShareMenu(false); + + if (!currentUser) { + showToast('Please log in to send posts via chat', 'error'); + return; + } + + router.push(`/chat?share=${encodeURIComponent(getAbsolutePostUrl())}`); + }; + + const handleCopyLink = async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setShowShareMenu(false); + + try { + await navigator.clipboard.writeText(getAbsolutePostUrl()); + showToast('Post link copied', 'success'); + } catch { + showToast('Could not copy the post link', 'error'); + } + }; + + const handleSystemShare = async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setShowShareMenu(false); + + const url = getAbsolutePostUrl(); + if (!navigator.share) { + try { + await navigator.clipboard.writeText(url); + showToast('Sharing is not available here, so the link was copied', 'success'); + } catch { + showToast('Sharing is not available in this browser', 'error'); + } + return; + } + + try { + await navigator.share({ + title: `${post.author.displayName || post.author.handle} on Synapsis`, + text: post.content || `Media from @${authorHandle}`, + url, + }); + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') return; + showToast('Could not share this post', 'error'); + } + }; + + const getDownloadName = (url: string, mimeType: string | null | undefined, index: number) => { + try { + const pathName = new URL(url, window.location.origin).pathname; + const existingName = decodeURIComponent(pathName.split('/').pop() || ''); + if (existingName && existingName.includes('.')) return existingName; + } catch { + // Fall through to a generated filename. + } + + const extension = mimeType?.split('/')[1]?.split(';')[0]?.replace('jpeg', 'jpg') || 'bin'; + return `synapsis-${post.author.handle}-${post.id}-${index + 1}.${extension}`; + }; + + const handleDownloadMedia = async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!post.media?.length || downloading) return; + + setDownloading(true); + try { + for (const [index, item] of post.media.entries()) { + const response = await fetch(item.url); + if (!response.ok) throw new Error(`Download failed with status ${response.status}`); + const blob = await response.blob(); + const objectUrl = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = objectUrl; + anchor.download = getDownloadName(item.url, item.mimeType || blob.type, index); + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000); + } + } catch { + showToast('Could not download this media', 'error'); + } finally { + setDownloading(false); + } + }; + // Get the full handle for profile links (includes domain for remote users) const getProfileHandle = () => { // If handle already has domain, use it @@ -919,30 +1017,81 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, {renderLinkPreviewCard()}
- - - - {!isOwnOrOwnedBotPost && ( - - )} - {canDeletePost && ( + + + {!isOwnOrOwnedBotPost && ( + + )} + {canDeletePost && ( )} +
+
+ {post.media && post.media.length > 0 && ( + + )} +
+ + {showShareMenu && ( + <> +
{ + e.preventDefault(); + e.stopPropagation(); + setShowShareMenu(false); + }} + /> +
e.stopPropagation()}> + + + +
+ + )} +
+