ソースを参照

Neo: Facebook-style reaction picker, shorter auto-link text (#5812)

* Neo: Facebook-style reaction picker, shorter auto-link text

- Reactions: tap/click applies the default reaction (like); the picker
  opens on long press (native) or mouse hover (web) and closes on hover out
- Links: auto-linked URLs show short text (no scheme/www, max 30 chars,
  domain kept whole); href stays full and goes into title
- Dropdown popup: scrolling its own list no longer repositions the popup

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Neo: open reaction picker with long press on touch web

Hover only works with a mouse, so on mobile web the picker could not be
opened. The web button now also gets onLongPress; the browser context
menu is suppressed and the click ending the long press is ignored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
RomanL 1 週間 前
親
コミット
da8faf1604

+ 37 - 0
neo/packages/app/lib/html-helpers.ts

@@ -37,6 +37,42 @@ export function normalizeAnchorHrefs(html: string) {
     })
     })
 }
 }
 
 
+const LINK_TEXT_MAX = 30
+
+const decodeAmp = (s: string) => s.replace(/&amp;/g, '&')
+const stripScheme = (s: string) => s.replace(/^https?:\/\//i, '').replace(/^www\./i, '').replace(/\/$/, '')
+
+// Display text for a bare URL: no scheme/www, domain always kept whole, rest cut with "…"
+export function shortenUrlText(url: string) {
+    const s = stripScheme(url)
+    if (s.length <= LINK_TEXT_MAX) return s
+    const hostLength = (s.split(/[/?#]/)[0] ?? s).length
+    return s.slice(0, Math.max(LINK_TEXT_MAX - 1, hostLength)) + '…'
+}
+
+// Auto-linked URLs (anchor text == href, or href already cut upstream with "..."/"…")
+// get short display text; href stays full. Anchors with custom text (mentions,
+// [text](url)) are left as is.
+export function shortenLinkTexts(html: string) {
+    if (!html || typeof html !== 'string') return html
+    return html.replace(/<a\b([^>]*?)\bhref=(["'])(.*?)\2([^>]*)>([^<]*)<\/a>/gi, (match, before: string, quote: string, href: string, after: string, text: string) => {
+        const textUrl = decodeAmp(text.trim())
+        const fullUrl = decodeAmp(href)
+        if (!/^(https?:\/\/|www\.)/i.test(textUrl))
+            return match
+        const cut = textUrl.match(/^(.*?)(\.\.\.|…)$/)
+        const isAutoLink = cut
+            ? stripScheme(fullUrl).startsWith(stripScheme(cut[1] ?? ''))
+            : stripScheme(textUrl) === stripScheme(fullUrl)
+        if (!isAutoLink || fullUrl.length <= LINK_TEXT_MAX)
+            return match
+        const short = shortenUrlText(fullUrl)
+        const escaped = short.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
+        const title = /\btitle=/i.test(before + after) ? '' : ` title=${quote}${href}${quote}`
+        return `<a${before}href=${quote}${href}${quote}${after}${title}>${escaped}</a>`
+    })
+}
+
 export const hasBlockHtml = (html: string) => (
 export const hasBlockHtml = (html: string) => (
     /<(p|div|ul|ol|li|h[1-6]|pre|blockquote|table|thead|tbody|tr)\b/i.test(html)
     /<(p|div|ul|ol|li|h[1-6]|pre|blockquote|table|thead|tbody|tr)\b/i.test(html)
 )
 )
@@ -52,6 +88,7 @@ export function preprocessHtml(data: any) {
     // where the editor didn't auto-link). Skips text already inside anchors/mentions/code.
     // where the editor didn't auto-link). Skips text already inside anchors/mentions/code.
     html = linkifyHtml(html)
     html = linkifyHtml(html)
     html = normalizeAnchorHrefs(html)
     html = normalizeAnchorHrefs(html)
+    html = shortenLinkTexts(html)
     if (html.trim() !== '' && !hasBlockHtml(html)) html = `<p>${html}</p>`
     if (html.trim() !== '' && !hasBlockHtml(html)) html = `<p>${html}</p>`
 
 
     return html
     return html

+ 6 - 1
neo/packages/app/ui/atoms/dropdown-popup.tsx

@@ -240,7 +240,12 @@ export default function DropdownPopup({
     /** Reposition when any scrollable ancestor scrolls (menu stays anchored to trigger). */
     /** Reposition when any scrollable ancestor scrolls (menu stays anchored to trigger). */
     useEffect(() => {
     useEffect(() => {
         if (!isWeb || !isRealOpen) return;
         if (!isWeb || !isRealOpen) return;
-        const onScroll = () => updateButtonPosition();
+        const onScroll = (event: Event) => {
+            // Scrolling the popup's own list doesn't move the trigger; re-measuring then
+            // shifts the popup (trigger was measured mid press-animation on open)
+            if (contentRef.current?.contains?.(event.target)) return;
+            updateButtonPosition();
+        };
         window.addEventListener('scroll', onScroll, true);
         window.addEventListener('scroll', onScroll, true);
         return () => window.removeEventListener('scroll', onScroll, true);
         return () => window.removeEventListener('scroll', onScroll, true);
     }, [isWeb, isRealOpen, updateButtonPosition]);
     }, [isWeb, isRealOpen, updateButtonPosition]);

+ 136 - 34
neo/packages/app/ui/molecules/objects/reactions.js

@@ -10,7 +10,7 @@
  *   primary                  — button variant=primary
  *   primary                  — button variant=primary
  */
  */
 
 
-import React, { useState, useRef } from 'react';
+import React, { useState, useRef, useEffect } from 'react';
 import {
 import {
     Modal as ReactNativeModal,
     Modal as ReactNativeModal,
     TouchableWithoutFeedback,
     TouchableWithoutFeedback,
@@ -226,14 +226,34 @@ function buildCounterCompound({
 
 
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
 // Reaction picker popover (emoji bar next to button)
 // Reaction picker popover (emoji bar next to button)
+//
+// Facebook-style: tap/click = default reaction (onDefault). The picker opens
+// on long press (native and touch web) or mouse hover (web); hovering out closes it.
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
 
 
+const HOVER_OPEN_DELAY = 500;
+const HOVER_CLOSE_DELAY = 300;
+const HOVER_SLOP = 8;
+// Ignore the click that ends the long press which opened the picker.
+const OPEN_CLICK_GUARD = 500;
+
+function isPointInRect(x, y, rect, slop = 0) {
+    if (!rect) return false;
+    return (
+        x >= rect.left - slop &&
+        x <= rect.right + slop &&
+        y >= rect.top - slop &&
+        y <= rect.bottom + slop
+    );
+}
+
 function ReactionPopover({
 function ReactionPopover({
     items,
     items,
     onTap,
     onTap,
+    onDefault,
     disabled,
     disabled,
+    haptics,
     children,
     children,
-    asChild = false,
     childRefProp = 'ref',
     childRefProp = 'ref',
 }) {
 }) {
     const { width: windowWidth, height: windowHeight } = useWindowSize();
     const { width: windowWidth, height: windowHeight } = useWindowSize();
@@ -245,6 +265,24 @@ function ReactionPopover({
         height: 0,
         height: 0,
     });
     });
     const buttonRef = useRef(null);
     const buttonRef = useRef(null);
+    const barRef = useRef(null);
+    const openTimer = useRef(null);
+    const closeTimer = useRef(null);
+    const openedAt = useRef(0);
+
+    const clearTimers = () => {
+        clearTimeout(openTimer.current);
+        clearTimeout(closeTimer.current);
+        openTimer.current = null;
+        closeTimer.current = null;
+    };
+
+    useEffect(() => clearTimers, []);
+
+    const closeModal = () => {
+        clearTimers();
+        setModalVisible(false);
+    };
 
 
     // RN host views have measureInWindow; the web View is a plain div, so fall
     // RN host views have measureInWindow; the web View is a plain div, so fall
     // back to its viewport rect (what RN-web's measureInWindow returns).
     // back to its viewport rect (what RN-web's measureInWindow returns).
@@ -274,17 +312,71 @@ function ReactionPopover({
             }
             }
 
 
             setButtonPos({ x: adjustedX, y: actY, width, height });
             setButtonPos({ x: adjustedX, y: actY, width, height });
+            openedAt.current = Date.now();
             setModalVisible(true);
             setModalVisible(true);
         });
         });
     };
     };
 
 
     const handleSelect = (item) => {
     const handleSelect = (item) => {
-        setModalVisible(false);
+        closeModal();
         onTap?.(item);
         onTap?.(item);
     };
     };
 
 
+    const handlePress = (event) => {
+        clearTimers();
+        onDefault?.(event);
+    };
+
+    const handleLongPress = () => {
+        if (disabled) return;
+        FeedbackHaptics(haptics);
+        openModal();
+    };
+
+    // --- web hover (mouse only; touch opens the picker with a long press) ---
+
+    const handlePointerEnter = (event) => {
+        if (disabled || modalVisible || event.pointerType !== 'mouse') return;
+        clearTimeout(openTimer.current);
+        openTimer.current = setTimeout(openModal, HOVER_OPEN_DELAY);
+    };
+
+    const handlePointerLeave = () => {
+        clearTimeout(openTimer.current);
+        openTimer.current = null;
+    };
+
+    // The overlay covers the button while open, so hit-test manually: keep
+    // the bar open while the pointer is over the button or the bar.
+    const isOverTarget = (x, y) =>
+        isPointInRect(x, y, barRef.current?.getBoundingClientRect?.(), HOVER_SLOP) ||
+        isPointInRect(x, y, buttonRef.current?.getBoundingClientRect?.(), HOVER_SLOP);
+
+    const handleOverlayMouseMove = (event) => {
+        if (isOverTarget(event.clientX, event.clientY)) {
+            clearTimeout(closeTimer.current);
+            closeTimer.current = null;
+        } else if (!closeTimer.current) {
+            closeTimer.current = setTimeout(closeModal, HOVER_CLOSE_DELAY);
+        }
+    };
+
+    const handleOverlayClick = (event) => {
+        // The modal is a portal: keep clicks from reaching the message row.
+        event.stopPropagation();
+        if (Date.now() - openedAt.current < OPEN_CLICK_GUARD) return;
+        const { clientX, clientY } = event;
+        if (isPointInRect(clientX, clientY, barRef.current?.getBoundingClientRect?.())) return;
+        closeModal();
+        // A click on the (covered) button still means "like", as on Facebook.
+        if (isPointInRect(clientX, clientY, buttonRef.current?.getBoundingClientRect?.())) {
+            onDefault?.(event);
+        }
+    };
+
     const reactionBar = (
     const reactionBar = (
         <View
         <View
+            ref={barRef}
             style={{
             style={{
                 top: buttonPos.y + buttonPos.height + 5,
                 top: buttonPos.y + buttonPos.height + 5,
                 left: buttonPos.x,
                 left: buttonPos.x,
@@ -326,46 +418,52 @@ function ReactionPopover({
             presentationStyle="overFullScreen"
             presentationStyle="overFullScreen"
             transparent={true}
             transparent={true}
             visible={modalVisible}
             visible={modalVisible}
-            onRequestClose={() => setModalVisible(false)}
+            onRequestClose={closeModal}
         >
         >
-            <TouchableWithoutFeedback onPress={() => setModalVisible(false)}>
-                <View className="flex-1 bg-transparent">
-                    {isWeb ? (
-                        <RemoveScroll>{reactionBar}</RemoveScroll>
-                    ) : (
-                        reactionBar
-                    )}
+            {isWeb ? (
+                <View
+                    className="flex-1 bg-transparent"
+                    onMouseMove={handleOverlayMouseMove}
+                    onClick={handleOverlayClick}
+                >
+                    <RemoveScroll>{reactionBar}</RemoveScroll>
                 </View>
                 </View>
-            </TouchableWithoutFeedback>
+            ) : (
+                <TouchableWithoutFeedback onPress={closeModal}>
+                    <View className="flex-1 bg-transparent">{reactionBar}</View>
+                </TouchableWithoutFeedback>
+            )}
         </ReactNativeModal>
         </ReactNativeModal>
     ) : null;
     ) : null;
 
 
-    if (asChild) {
-        const childProps = {
-            [childRefProp]: buttonRef,
-            onPress: disabled ? undefined : openModal,
-        };
+    const button =
+        children && typeof children === 'object'
+            ? React.cloneElement(children, {
+                  onPress: disabled ? undefined : handlePress,
+                  onLongPress: disabled ? undefined : handleLongPress,
+                  ...(isWeb ? {} : { [childRefProp]: buttonRef }),
+              })
+            : null;
+
+    if (isWeb) {
         return (
         return (
-            <View>
-                {children && typeof children === 'object' ? (
-                    <>
-                        {React.cloneElement(children, childProps)}
-                        {overlay}
-                    </>
-                ) : null}
+            <View
+                ref={buttonRef}
+                onPointerEnter={handlePointerEnter}
+                onPointerLeave={handlePointerLeave}
+                // Long press on touch opens the picker, not the browser menu
+                onContextMenu={(event) => event.preventDefault()}
+                className="select-none"
+            >
+                {button}
+                {overlay}
             </View>
             </View>
         );
         );
     }
     }
 
 
     return (
     return (
         <View>
         <View>
-            <TouchableOpacity
-                ref={buttonRef}
-                onPress={openModal}
-                disabled={disabled}
-            >
-                {children}
-            </TouchableOpacity>
+            {button}
             {overlay}
             {overlay}
         </View>
         </View>
     );
     );
@@ -546,18 +644,22 @@ export default function ElementReactions(props) {
                 title: t('rvote_' + item.name + '_title'),
                 title: t('rvote_' + item.name + '_title'),
             }));
             }));
 
 
+            // Plain tap/click applies the default reaction ("like"), like Facebook.
+            const defaultReaction = (
+                items.find((item) => item.name === 'like') || items[0]
+            )?.name;
+
             actionButton =
             actionButton =
                 items.length > 1 ? (
                 items.length > 1 ? (
                     <ReactionPopover
                     <ReactionPopover
                         key="action"
                         key="action"
-                        type="modal"
-                        showPopupType="onPress"
                         items={reactionItems}
                         items={reactionItems}
                         onTap={(item) => {
                         onTap={(item) => {
                             doReaction(item.name);
                             doReaction(item.name);
                         }}
                         }}
+                        onDefault={() => doReaction(defaultReaction)}
                         disabled={isDisabled}
                         disabled={isDisabled}
-                        asChild
+                        haptics={params.haptics_type}
                         childRefProp="forwardedRef"
                         childRefProp="forwardedRef"
                     >
                     >
                         <ActionButton
                         <ActionButton