diff --git a/.claude/hooks/require-linear-ticket.sh b/.claude/hooks/require-linear-ticket.sh new file mode 100755 index 0000000000..3107e154f6 --- /dev/null +++ b/.claude/hooks/require-linear-ticket.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# PreToolUse hook: block PR creation unless a Linear ticket ID is present in +# the PR title or PR body. The branch name does not count. +# +# Acts on: +# - Bash commands that run `gh pr create` (or its alias `gh pr new`) +# - MCP tools whose name looks like a PR-creation tool +# (e.g. mcp__github__create_pull_request) +# Every other tool call exits 0. +# +# Team keys default to REACT, RN, CHAT and VID; override with LINEAR_TEAM_KEYS="A B C". +# If LINEAR_API_KEY is set, the ticket is also verified against Linear's API. +# +# Requires: bash, jq, curl. + +set -uo pipefail + +TEAM_KEYS="${LINEAR_TEAM_KEYS:-REACT RN CHAT VID}" +KEYS_ALT="$(echo "$TEAM_KEYS" | tr -s ' ,' '|' | sed 's/^|//; s/|$//')" +MCP_PR_TOOL_RE='^mcp__.*(create_pull_request|create_pr|pull_request_create|open_pull_request)' +GH_PR_CREATE_RE='(^|[;&|(`]|\$\()[[:space:]]*([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+)*gh[[:space:]]+pr[[:space:]]+(create|new)([[:space:]]|$)' + +input="$(cat)" + +if ! command -v jq >/dev/null 2>&1; then + # Without jq we can't parse the payload; only block if this looks like PR creation. + if printf '%s' "$input" | grep -qE 'gh[[:space:]]+pr[[:space:]]+(create|new)|create_pull_request'; then + echo "require-linear-ticket hook: jq is not installed, so the Linear ticket check cannot run. Install jq (e.g. 'brew install jq') and retry." >&2 + exit 2 + fi + exit 0 +fi + +tool_name="$(printf '%s' "$input" | jq -r '.tool_name // empty')" +cwd="$(printf '%s' "$input" | jq -r '.cwd // empty')" +[ -n "$cwd" ] && [ -d "$cwd" ] || cwd="${CLAUDE_PROJECT_DIR:-$PWD}" + +haystack="" + +if [ "$tool_name" = "Bash" ]; then + cmd="$(printf '%s' "$input" | jq -r '.tool_input.command // empty')" + printf '%s\n' "$cmd" | grep -qE "$GH_PR_CREATE_RE" || exit 0 + + # The inline title/body (including heredocs) are part of the command string. + # Scan from `gh pr create` onward, so earlier commands (e.g. `git switch -c + # react-123-...`) don't count. + pr_cmd="$(printf '%s\n' "$cmd" \ + | awk 'found { print; next } match($0, /gh[[:space:]]+pr[[:space:]]+(create|new)/) { print substr($0, RSTART); found = 1 }')" + # Drop the values of flags that aren't the title or body (branch, repo, + # labels, assignees, ...), so e.g. `--label REACT-123` doesn't count. + haystack="$(printf '%s\n' "$pr_cmd" \ + | sed -E 's/(^|[[:space:]])(--head|-H|--base|-B|--repo|-R|--label|-l|--assignee|-a|--reviewer|-r|--milestone|-m|--project|-p)([= ]+)("[^"]*"|'\''[^'\'']*'\''|[^[:space:]]+)/\1/g')" + + # --body-file/-F of this `gh pr create`, without a trailing `;`, `&&`, ... + body_file="$(printf '%s' "$pr_cmd" | grep -oE -- '(--body-file|-F)[= ]+("[^"]*"|'\''[^'\'']*'\''|[^[:space:];&|)]+)' | head -n1 | sed -E 's/^(--body-file|-F)[= ]+//; s/["'\'']//g')" + if [ -n "$body_file" ] && [ "$body_file" != "-" ]; then + case "$body_file" in /*) ;; *) body_file="$cwd/$body_file" ;; esac + [ -f "$body_file" ] && haystack="$haystack"$'\n'"$(cat "$body_file")" + fi +elif printf '%s' "$tool_name" | grep -qiE "$MCP_PR_TOOL_RE"; then + haystack="$(printf '%s' "$input" | jq -r '[.tool_input.title, .tool_input.body] | map(select(. != null) | tostring) | join("\n")')" +else + exit 0 +fi + +# Split into tokens of letters, digits and dashes, so an ID only counts when it +# isn't glued to other letters or digits (REACT-123abc, NOTREACT-1). +ids="$(printf '%s\n' "$haystack" \ + | tr -c '[:alnum:]-' '\n' \ + | grep -oiE "(^|-)($KEYS_ALT)-[0-9]+(-|$)" \ + | grep -oiE "($KEYS_ALT)-[0-9]+" \ + | tr '[:lower:]' '[:upper:]' | awk '!seen[$0]++')" + +if [ -z "$ids" ]; then + cat >&2 </dev/null)" || { + echo "require-linear-ticket hook: could not reach Linear to verify $id; allowing." >&2 + exit 0 + } + if printf '%s' "$resp" | jq -e '.data.issue.id // empty' >/dev/null 2>&1; then + found="$id" + break + fi + # Only a "not found" answer counts against the ticket; auth errors or + # unexpected responses mean we can't verify, so don't block on them. + if ! printf '%s' "$resp" | jq -e '[.errors[]?.message] | any(test("not found"; "i"))' >/dev/null 2>&1; then + msg="$(printf '%s' "$resp" | jq -r '.errors[0].message // "unexpected response"' 2>/dev/null)" + echo "require-linear-ticket hook: could not verify $id with Linear (${msg:-unexpected response}); allowing." >&2 + exit 0 + fi + done + if [ -z "$found" ]; then + cat >&2 </dev/null || true + if [ "${#lintable[@]}" -gt 0 ]; then + "$ESLINT" --fix --no-warn-ignored --cache --cache-location node_modules/.cache/eslint/ \ + "${lintable[@]}" >/dev/null || true + fi + echo "some files were not formatted correctly (prettier/eslint) commit aborted!" + echo "your changes are still staged, you can accept formatting changes with git add or ignore them by adding --no-verify to git commit" + exit 1 fi diff --git a/dotgit/hooks/pre-commit-reject-binaries.py b/dotgit/hooks/pre-commit-reject-binaries.py index efc532d2bc..014bf44ffa 100755 --- a/dotgit/hooks/pre-commit-reject-binaries.py +++ b/dotgit/hooks/pre-commit-reject-binaries.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 import os import subprocess import sys diff --git a/examples/ExpoChatGPTSample/package.json b/examples/ExpoChatGPTSample/package.json index 777607b375..c044710ef1 100644 --- a/examples/ExpoChatGPTSample/package.json +++ b/examples/ExpoChatGPTSample/package.json @@ -21,14 +21,14 @@ "expo-router": "~58.0.18", "react": "19.3.0", "react-native": "0.88.0", - "react-native-gesture-handler": "~3.3.0", + "react-native-gesture-handler": "~3.4.0", "react-native-reanimated": "4.7.1", "react-native-safe-area-context": "~5.10.1", "react-native-screens": "~4.29.0", "react-native-svg": "15.15.5", "react-native-teleport": "^1.2.2", "react-native-worklets": "0.13.0", - "stream-chat": "^9.54.0", + "stream-chat": "^9.54.1", "stream-chat-expo": "workspace:^", "stream-chat-react-native-core": "workspace:^", "victory-native": "^42.0.1" diff --git a/examples/ExpoMessaging/package.json b/examples/ExpoMessaging/package.json index aa3300880f..814aced8bc 100644 --- a/examples/ExpoMessaging/package.json +++ b/examples/ExpoMessaging/package.json @@ -41,7 +41,7 @@ "react": "19.3.0", "react-dom": "19.3.0", "react-native": "0.88.0", - "react-native-gesture-handler": "~3.3.0", + "react-native-gesture-handler": "~3.4.0", "react-native-keyboard-controller": "1.22.7", "react-native-maps": "1.29.11", "react-native-reanimated": "4.7.1", @@ -51,7 +51,7 @@ "react-native-teleport": "^1.2.2", "react-native-web": "~0.21.4", "react-native-worklets": "0.13.0", - "stream-chat": "^9.54.0", + "stream-chat": "^9.54.1", "stream-chat-expo": "workspace:^", "stream-chat-react-native-core": "workspace:^" }, diff --git a/examples/SampleApp/package.json b/examples/SampleApp/package.json index cd77726430..b363f9cad5 100644 --- a/examples/SampleApp/package.json +++ b/examples/SampleApp/package.json @@ -50,7 +50,7 @@ "react": "19.3.0", "react-native": "0.88.0", "react-native-blob-util": "^0.24.12", - "react-native-gesture-handler": "^3.3.0", + "react-native-gesture-handler": "^3.4.0", "react-native-haptic-feedback": "^3.0.0", "react-native-image-picker": "^8.2.1", "react-native-maps": "^1.29.11", @@ -64,7 +64,7 @@ "react-native-teleport": "^1.2.2", "react-native-video": "^6.19.3", "react-native-worklets": "^0.13.0", - "stream-chat": "^9.54.0", + "stream-chat": "^9.54.1", "stream-chat-react-native": "workspace:^", "stream-chat-react-native-core": "workspace:^" }, diff --git a/package/package.json b/package/package.json index 66a324002c..920b290ebf 100644 --- a/package/package.json +++ b/package/package.json @@ -84,7 +84,7 @@ "react-native-markdown-package": "1.8.2", "react-native-url-polyfill": "^2.0.0", "react-syntax-highlighter": "^16.1.0", - "stream-chat": "^9.54.0", + "stream-chat": "^9.54.1", "use-sync-external-store": "^1.5.0", "zod": "^4.1.12" }, @@ -158,7 +158,7 @@ "react": "19.3.0", "react-native": "0.88.0", "react-native-builder-bob": "0.40.11", - "react-native-gesture-handler": "^3.3.0", + "react-native-gesture-handler": "^3.4.0", "react-native-reanimated": "^4.7.1", "react-native-safe-area-context": "^5.10.1", "react-native-svg": "15.15.5", diff --git a/package/src/components/ChannelPreview/hooks/__tests__/useChannelPreviewPollLabel.test.tsx b/package/src/components/ChannelPreview/hooks/__tests__/useChannelPreviewPollLabel.test.tsx new file mode 100644 index 0000000000..51980c760a --- /dev/null +++ b/package/src/components/ChannelPreview/hooks/__tests__/useChannelPreviewPollLabel.test.tsx @@ -0,0 +1,86 @@ +import React, { PropsWithChildren } from 'react'; + +import { act, renderHook } from '@testing-library/react-native'; +import { Poll, PollResponse, PollVote, StreamChat } from 'stream-chat'; + +import { ChatProvider } from '../../../../contexts/chatContext/ChatContext'; +import { useChannelPreviewPollLabel } from '../useChannelPreviewPollLabel'; + +const OWN_USER = { id: 'own-user' }; +const MESSAGE_ID = 'message-id'; +const POLL_ID = 'poll-id'; + +const generateVote = (id: string, optionId: string, createdAt: string): PollVote => ({ + created_at: createdAt, + id, + option_id: optionId, + poll_id: POLL_ID, + updated_at: createdAt, + user: OWN_USER, + user_id: OWN_USER.id, +}); + +const setup = (ownVotes: PollVote[]) => { + const client = { + removePollVote: jest.fn(() => new Promise(() => {})), + user: OWN_USER, + userID: OWN_USER.id, + } as unknown as StreamChat; + const latestVotesByOption: Record = {}; + const voteCountsByOption: Record = {}; + ownVotes.forEach((vote) => { + latestVotesByOption[vote.option_id as string] = [vote]; + voteCountsByOption[vote.option_id as string] = 1; + }); + const poll = new Poll({ + client, + poll: { + enforce_unique_vote: false, + id: POLL_ID, + latest_answers: [], + latest_votes_by_option: latestVotesByOption, + name: 'Poll', + options: [ + { id: 'option-a', text: 'A' }, + { id: 'option-b', text: 'B' }, + ], + own_votes: ownVotes, + vote_count: ownVotes.length, + vote_counts_by_option: voteCountsByOption, + } as unknown as PollResponse, + }); + (client as unknown as { polls: unknown }).polls = { fromState: () => poll }; + + const wrapper = ({ children }: PropsWithChildren) => ( + {children} + ); + const hook = renderHook(() => useChannelPreviewPollLabel({ pollId: POLL_ID }), { wrapper }); + return { ...hook, poll }; +}; + +describe('useChannelPreviewPollLabel', () => { + it('returns no label once the only vote is removed', () => { + const vote = generateVote('vote-a', 'option-a', '2026-10-27T10:00:00.000Z'); + const { poll, result } = setup([vote]); + expect(result.current).toBe('You voted: {{ option }}'); + + act(() => { + poll.removeVote(vote.id, MESSAGE_ID); + }); + + expect(result.current).toBeUndefined(); + }); + + it('labels the remaining latest vote once another option loses its only vote', () => { + const voteA = generateVote('vote-a', 'option-a', '2026-10-27T10:00:00.000Z'); + const voteB = generateVote('vote-b', 'option-b', '2026-10-27T09:00:00.000Z'); + const { poll, result } = setup([voteA, voteB]); + + act(() => { + poll.removeVote(voteA.id, MESSAGE_ID); + }); + + expect(poll.data.latest_votes_by_option['option-a']).toEqual([]); + expect(result.current).toBe('You voted: {{ option }}'); + }); +}); diff --git a/package/src/components/ChannelPreview/hooks/useChannelPreviewPollLabel.ts b/package/src/components/ChannelPreview/hooks/useChannelPreviewPollLabel.ts index af1048ad4b..733e93bf0a 100644 --- a/package/src/components/ChannelPreview/hooks/useChannelPreviewPollLabel.ts +++ b/package/src/components/ChannelPreview/hooks/useChannelPreviewPollLabel.ts @@ -31,7 +31,7 @@ export const useChannelPreviewPollLabel = ({ pollId }: UseChannelPreviewPollLabe () => latestVotesByOption ? Object.values(latestVotesByOption) - .map((votes) => votes?.[0]) + .flatMap((votes) => votes?.[0] ?? []) .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) : [], [latestVotesByOption], diff --git a/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.tsx.snap b/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.tsx.snap index b8367c063a..a7f0495e78 100644 --- a/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.tsx.snap +++ b/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.tsx.snap @@ -70,8 +70,10 @@ exports[`AttachButton should call handleAttachButtonPress when the button is cli } hoverAnimationInDuration={50} hoverAnimationOutDuration={100} + immediatePressOut={false} longPressAnimationOutDuration={100} longPressDuration={500} + moduleId={-1} onButtonInteractionFinished={[Function]} onButtonLongPress={[Function]} onButtonPress={[Function]} @@ -99,7 +101,7 @@ exports[`AttachButton should call handleAttachButtonPress when the button is cli ], ] } - tapAnimationInDuration={50} + tapAnimationInDuration={130} tapAnimationOutDuration={100} testID="attach-button" testOnly_onPress={[Function]} @@ -252,8 +254,10 @@ exports[`AttachButton should call handleAttachButtonPress when the button is cli } hoverAnimationInDuration={50} hoverAnimationOutDuration={100} + immediatePressOut={false} longPressAnimationOutDuration={100} longPressDuration={500} + moduleId={-1} onButtonInteractionFinished={[Function]} onButtonLongPress={[Function]} onButtonPress={[Function]} @@ -281,7 +285,7 @@ exports[`AttachButton should call handleAttachButtonPress when the button is cli ], ] } - tapAnimationInDuration={50} + tapAnimationInDuration={130} tapAnimationOutDuration={100} testID="upload-photo-touchable" testOnly_onPress={[Function]} @@ -407,8 +411,10 @@ exports[`AttachButton should call handleAttachButtonPress when the button is cli } hoverAnimationInDuration={50} hoverAnimationOutDuration={100} + immediatePressOut={false} longPressAnimationOutDuration={100} longPressDuration={500} + moduleId={-1} onButtonInteractionFinished={[Function]} onButtonLongPress={[Function]} onButtonPress={[Function]} @@ -436,7 +442,7 @@ exports[`AttachButton should call handleAttachButtonPress when the button is cli ], ] } - tapAnimationInDuration={50} + tapAnimationInDuration={130} tapAnimationOutDuration={100} testID="take-photo-touchable" testOnly_onPress={[Function]} @@ -591,8 +597,10 @@ exports[`AttachButton should call handleAttachButtonPress when the button is cli } hoverAnimationInDuration={50} hoverAnimationOutDuration={100} + immediatePressOut={false} longPressAnimationOutDuration={100} longPressDuration={500} + moduleId={-1} onButtonInteractionFinished={[Function]} onButtonLongPress={[Function]} onButtonPress={[Function]} @@ -620,7 +628,7 @@ exports[`AttachButton should call handleAttachButtonPress when the button is cli ], ] } - tapAnimationInDuration={50} + tapAnimationInDuration={130} tapAnimationOutDuration={100} testID="upload-file-touchable" testOnly_onPress={[Function]} @@ -742,8 +750,10 @@ exports[`AttachButton should call handleAttachButtonPress when the button is cli } hoverAnimationInDuration={50} hoverAnimationOutDuration={100} + immediatePressOut={false} longPressAnimationOutDuration={100} longPressDuration={500} + moduleId={-1} onButtonInteractionFinished={[Function]} onButtonLongPress={[Function]} onButtonPress={[Function]} @@ -771,7 +781,7 @@ exports[`AttachButton should call handleAttachButtonPress when the button is cli ], ] } - tapAnimationInDuration={50} + tapAnimationInDuration={130} tapAnimationOutDuration={100} testID="commands-touchable" testOnly_onPress={[Function]} @@ -1026,8 +1036,10 @@ exports[`AttachButton should render a enabled AttachButton 1`] = ` } hoverAnimationInDuration={50} hoverAnimationOutDuration={100} + immediatePressOut={false} longPressAnimationOutDuration={100} longPressDuration={500} + moduleId={-1} onButtonInteractionFinished={[Function]} onButtonLongPress={[Function]} onButtonPress={[Function]} @@ -1055,7 +1067,7 @@ exports[`AttachButton should render a enabled AttachButton 1`] = ` ], ] } - tapAnimationInDuration={50} + tapAnimationInDuration={130} tapAnimationOutDuration={100} testID="attach-button" testOnly_onPress={[Function]} @@ -1208,8 +1220,10 @@ exports[`AttachButton should render a enabled AttachButton 1`] = ` } hoverAnimationInDuration={50} hoverAnimationOutDuration={100} + immediatePressOut={false} longPressAnimationOutDuration={100} longPressDuration={500} + moduleId={-1} onButtonInteractionFinished={[Function]} onButtonLongPress={[Function]} onButtonPress={[Function]} @@ -1237,7 +1251,7 @@ exports[`AttachButton should render a enabled AttachButton 1`] = ` ], ] } - tapAnimationInDuration={50} + tapAnimationInDuration={130} tapAnimationOutDuration={100} testID="upload-photo-touchable" testOnly_onPress={[Function]} @@ -1363,8 +1377,10 @@ exports[`AttachButton should render a enabled AttachButton 1`] = ` } hoverAnimationInDuration={50} hoverAnimationOutDuration={100} + immediatePressOut={false} longPressAnimationOutDuration={100} longPressDuration={500} + moduleId={-1} onButtonInteractionFinished={[Function]} onButtonLongPress={[Function]} onButtonPress={[Function]} @@ -1392,7 +1408,7 @@ exports[`AttachButton should render a enabled AttachButton 1`] = ` ], ] } - tapAnimationInDuration={50} + tapAnimationInDuration={130} tapAnimationOutDuration={100} testID="take-photo-touchable" testOnly_onPress={[Function]} @@ -1547,8 +1563,10 @@ exports[`AttachButton should render a enabled AttachButton 1`] = ` } hoverAnimationInDuration={50} hoverAnimationOutDuration={100} + immediatePressOut={false} longPressAnimationOutDuration={100} longPressDuration={500} + moduleId={-1} onButtonInteractionFinished={[Function]} onButtonLongPress={[Function]} onButtonPress={[Function]} @@ -1576,7 +1594,7 @@ exports[`AttachButton should render a enabled AttachButton 1`] = ` ], ] } - tapAnimationInDuration={50} + tapAnimationInDuration={130} tapAnimationOutDuration={100} testID="upload-file-touchable" testOnly_onPress={[Function]} @@ -1698,8 +1716,10 @@ exports[`AttachButton should render a enabled AttachButton 1`] = ` } hoverAnimationInDuration={50} hoverAnimationOutDuration={100} + immediatePressOut={false} longPressAnimationOutDuration={100} longPressDuration={500} + moduleId={-1} onButtonInteractionFinished={[Function]} onButtonLongPress={[Function]} onButtonPress={[Function]} @@ -1727,7 +1747,7 @@ exports[`AttachButton should render a enabled AttachButton 1`] = ` ], ] } - tapAnimationInDuration={50} + tapAnimationInDuration={130} tapAnimationOutDuration={100} testID="commands-touchable" testOnly_onPress={[Function]} @@ -1982,8 +2002,10 @@ exports[`AttachButton should render an disabled AttachButton 1`] = ` } hoverAnimationInDuration={50} hoverAnimationOutDuration={100} + immediatePressOut={false} longPressAnimationOutDuration={100} longPressDuration={500} + moduleId={-1} onButtonInteractionFinished={[Function]} onButtonLongPress={[Function]} onButtonPress={[Function]} @@ -2011,7 +2033,7 @@ exports[`AttachButton should render an disabled AttachButton 1`] = ` ], ] } - tapAnimationInDuration={50} + tapAnimationInDuration={130} tapAnimationOutDuration={100} testID="attach-button" testOnly_onPress={[Function]} @@ -2164,8 +2186,10 @@ exports[`AttachButton should render an disabled AttachButton 1`] = ` } hoverAnimationInDuration={50} hoverAnimationOutDuration={100} + immediatePressOut={false} longPressAnimationOutDuration={100} longPressDuration={500} + moduleId={-1} onButtonInteractionFinished={[Function]} onButtonLongPress={[Function]} onButtonPress={[Function]} @@ -2193,7 +2217,7 @@ exports[`AttachButton should render an disabled AttachButton 1`] = ` ], ] } - tapAnimationInDuration={50} + tapAnimationInDuration={130} tapAnimationOutDuration={100} testID="upload-photo-touchable" testOnly_onPress={[Function]} @@ -2319,8 +2343,10 @@ exports[`AttachButton should render an disabled AttachButton 1`] = ` } hoverAnimationInDuration={50} hoverAnimationOutDuration={100} + immediatePressOut={false} longPressAnimationOutDuration={100} longPressDuration={500} + moduleId={-1} onButtonInteractionFinished={[Function]} onButtonLongPress={[Function]} onButtonPress={[Function]} @@ -2348,7 +2374,7 @@ exports[`AttachButton should render an disabled AttachButton 1`] = ` ], ] } - tapAnimationInDuration={50} + tapAnimationInDuration={130} tapAnimationOutDuration={100} testID="take-photo-touchable" testOnly_onPress={[Function]} @@ -2503,8 +2529,10 @@ exports[`AttachButton should render an disabled AttachButton 1`] = ` } hoverAnimationInDuration={50} hoverAnimationOutDuration={100} + immediatePressOut={false} longPressAnimationOutDuration={100} longPressDuration={500} + moduleId={-1} onButtonInteractionFinished={[Function]} onButtonLongPress={[Function]} onButtonPress={[Function]} @@ -2532,7 +2560,7 @@ exports[`AttachButton should render an disabled AttachButton 1`] = ` ], ] } - tapAnimationInDuration={50} + tapAnimationInDuration={130} tapAnimationOutDuration={100} testID="upload-file-touchable" testOnly_onPress={[Function]} @@ -2654,8 +2682,10 @@ exports[`AttachButton should render an disabled AttachButton 1`] = ` } hoverAnimationInDuration={50} hoverAnimationOutDuration={100} + immediatePressOut={false} longPressAnimationOutDuration={100} longPressDuration={500} + moduleId={-1} onButtonInteractionFinished={[Function]} onButtonLongPress={[Function]} onButtonPress={[Function]} @@ -2683,7 +2713,7 @@ exports[`AttachButton should render an disabled AttachButton 1`] = ` ], ] } - tapAnimationInDuration={50} + tapAnimationInDuration={130} tapAnimationOutDuration={100} testID="commands-touchable" testOnly_onPress={[Function]} diff --git a/package/src/components/MessageInput/__tests__/__snapshots__/SendButton.test.tsx.snap b/package/src/components/MessageInput/__tests__/__snapshots__/SendButton.test.tsx.snap index f67cb282ee..0c09b32ceb 100644 --- a/package/src/components/MessageInput/__tests__/__snapshots__/SendButton.test.tsx.snap +++ b/package/src/components/MessageInput/__tests__/__snapshots__/SendButton.test.tsx.snap @@ -70,8 +70,10 @@ exports[`SendButton should render a SendButton 1`] = ` } hoverAnimationInDuration={50} hoverAnimationOutDuration={100} + immediatePressOut={false} longPressAnimationOutDuration={100} longPressDuration={500} + moduleId={-1} onButtonInteractionFinished={[Function]} onButtonLongPress={[Function]} onButtonPress={[Function]} @@ -99,7 +101,7 @@ exports[`SendButton should render a SendButton 1`] = ` ], ] } - tapAnimationInDuration={50} + tapAnimationInDuration={130} tapAnimationOutDuration={100} testID="send-button" testOnly_onPress={[Function]} @@ -250,8 +252,10 @@ exports[`SendButton should render a SendButton 1`] = ` } hoverAnimationInDuration={50} hoverAnimationOutDuration={100} + immediatePressOut={false} longPressAnimationOutDuration={100} longPressDuration={500} + moduleId={-1} onButtonInteractionFinished={[Function]} onButtonLongPress={[Function]} onButtonPress={[Function]} @@ -279,7 +283,7 @@ exports[`SendButton should render a SendButton 1`] = ` ], ] } - tapAnimationInDuration={50} + tapAnimationInDuration={130} tapAnimationOutDuration={100} testID="upload-photo-touchable" testOnly_onPress={[Function]} @@ -405,8 +409,10 @@ exports[`SendButton should render a SendButton 1`] = ` } hoverAnimationInDuration={50} hoverAnimationOutDuration={100} + immediatePressOut={false} longPressAnimationOutDuration={100} longPressDuration={500} + moduleId={-1} onButtonInteractionFinished={[Function]} onButtonLongPress={[Function]} onButtonPress={[Function]} @@ -434,7 +440,7 @@ exports[`SendButton should render a SendButton 1`] = ` ], ] } - tapAnimationInDuration={50} + tapAnimationInDuration={130} tapAnimationOutDuration={100} testID="take-photo-touchable" testOnly_onPress={[Function]} @@ -589,8 +595,10 @@ exports[`SendButton should render a SendButton 1`] = ` } hoverAnimationInDuration={50} hoverAnimationOutDuration={100} + immediatePressOut={false} longPressAnimationOutDuration={100} longPressDuration={500} + moduleId={-1} onButtonInteractionFinished={[Function]} onButtonLongPress={[Function]} onButtonPress={[Function]} @@ -618,7 +626,7 @@ exports[`SendButton should render a SendButton 1`] = ` ], ] } - tapAnimationInDuration={50} + tapAnimationInDuration={130} tapAnimationOutDuration={100} testID="upload-file-touchable" testOnly_onPress={[Function]} @@ -740,8 +748,10 @@ exports[`SendButton should render a SendButton 1`] = ` } hoverAnimationInDuration={50} hoverAnimationOutDuration={100} + immediatePressOut={false} longPressAnimationOutDuration={100} longPressDuration={500} + moduleId={-1} onButtonInteractionFinished={[Function]} onButtonLongPress={[Function]} onButtonPress={[Function]} @@ -769,7 +779,7 @@ exports[`SendButton should render a SendButton 1`] = ` ], ] } - tapAnimationInDuration={50} + tapAnimationInDuration={130} tapAnimationOutDuration={100} testID="commands-touchable" testOnly_onPress={[Function]} @@ -1024,8 +1034,10 @@ exports[`SendButton should render a disabled SendButton 1`] = ` } hoverAnimationInDuration={50} hoverAnimationOutDuration={100} + immediatePressOut={false} longPressAnimationOutDuration={100} longPressDuration={500} + moduleId={-1} onButtonInteractionFinished={[Function]} onButtonLongPress={[Function]} onButtonPress={[Function]} @@ -1053,7 +1065,7 @@ exports[`SendButton should render a disabled SendButton 1`] = ` ], ] } - tapAnimationInDuration={50} + tapAnimationInDuration={130} tapAnimationOutDuration={100} testID="send-button" testOnly_onPress={[Function]} @@ -1204,8 +1216,10 @@ exports[`SendButton should render a disabled SendButton 1`] = ` } hoverAnimationInDuration={50} hoverAnimationOutDuration={100} + immediatePressOut={false} longPressAnimationOutDuration={100} longPressDuration={500} + moduleId={-1} onButtonInteractionFinished={[Function]} onButtonLongPress={[Function]} onButtonPress={[Function]} @@ -1233,7 +1247,7 @@ exports[`SendButton should render a disabled SendButton 1`] = ` ], ] } - tapAnimationInDuration={50} + tapAnimationInDuration={130} tapAnimationOutDuration={100} testID="upload-photo-touchable" testOnly_onPress={[Function]} @@ -1359,8 +1373,10 @@ exports[`SendButton should render a disabled SendButton 1`] = ` } hoverAnimationInDuration={50} hoverAnimationOutDuration={100} + immediatePressOut={false} longPressAnimationOutDuration={100} longPressDuration={500} + moduleId={-1} onButtonInteractionFinished={[Function]} onButtonLongPress={[Function]} onButtonPress={[Function]} @@ -1388,7 +1404,7 @@ exports[`SendButton should render a disabled SendButton 1`] = ` ], ] } - tapAnimationInDuration={50} + tapAnimationInDuration={130} tapAnimationOutDuration={100} testID="take-photo-touchable" testOnly_onPress={[Function]} @@ -1543,8 +1559,10 @@ exports[`SendButton should render a disabled SendButton 1`] = ` } hoverAnimationInDuration={50} hoverAnimationOutDuration={100} + immediatePressOut={false} longPressAnimationOutDuration={100} longPressDuration={500} + moduleId={-1} onButtonInteractionFinished={[Function]} onButtonLongPress={[Function]} onButtonPress={[Function]} @@ -1572,7 +1590,7 @@ exports[`SendButton should render a disabled SendButton 1`] = ` ], ] } - tapAnimationInDuration={50} + tapAnimationInDuration={130} tapAnimationOutDuration={100} testID="upload-file-touchable" testOnly_onPress={[Function]} @@ -1694,8 +1712,10 @@ exports[`SendButton should render a disabled SendButton 1`] = ` } hoverAnimationInDuration={50} hoverAnimationOutDuration={100} + immediatePressOut={false} longPressAnimationOutDuration={100} longPressDuration={500} + moduleId={-1} onButtonInteractionFinished={[Function]} onButtonLongPress={[Function]} onButtonPress={[Function]} @@ -1723,7 +1743,7 @@ exports[`SendButton should render a disabled SendButton 1`] = ` ], ] } - tapAnimationInDuration={50} + tapAnimationInDuration={130} tapAnimationOutDuration={100} testID="commands-touchable" testOnly_onPress={[Function]} diff --git a/package/src/components/MessageList/__tests__/__snapshots__/ScrollToBottomButton.test.tsx.snap b/package/src/components/MessageList/__tests__/__snapshots__/ScrollToBottomButton.test.tsx.snap index 01525c643a..ad7842d870 100644 --- a/package/src/components/MessageList/__tests__/__snapshots__/ScrollToBottomButton.test.tsx.snap +++ b/package/src/components/MessageList/__tests__/__snapshots__/ScrollToBottomButton.test.tsx.snap @@ -84,8 +84,10 @@ exports[`ScrollToBottomButton should render the message notification and match s } hoverAnimationInDuration={50} hoverAnimationOutDuration={100} + immediatePressOut={false} longPressAnimationOutDuration={100} longPressDuration={500} + moduleId={-1} onButtonInteractionFinished={[Function]} onButtonLongPress={[Function]} onButtonPress={[Function]} @@ -113,7 +115,7 @@ exports[`ScrollToBottomButton should render the message notification and match s ], ] } - tapAnimationInDuration={50} + tapAnimationInDuration={130} tapAnimationOutDuration={100} testID="scroll-to-bottom-button" testOnly_onPress={[Function]} diff --git a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.tsx.snap b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.tsx.snap index b901232897..80f96fda69 100644 --- a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.tsx.snap +++ b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.tsx.snap @@ -1869,8 +1869,10 @@ exports[`Thread should match thread snapshot 1`] = ` } hoverAnimationInDuration={50} hoverAnimationOutDuration={100} + immediatePressOut={false} longPressAnimationOutDuration={100} longPressDuration={500} + moduleId={-1} onButtonInteractionFinished={[Function]} onButtonLongPress={[Function]} onButtonPress={[Function]} @@ -1898,7 +1900,7 @@ exports[`Thread should match thread snapshot 1`] = ` ], ] } - tapAnimationInDuration={50} + tapAnimationInDuration={130} tapAnimationOutDuration={100} testID="attach-button" testOnly_onPress={[Function]} @@ -2255,8 +2257,10 @@ exports[`Thread should match thread snapshot 1`] = ` } hoverAnimationInDuration={50} hoverAnimationOutDuration={100} + immediatePressOut={false} longPressAnimationOutDuration={100} longPressDuration={500} + moduleId={-1} onButtonInteractionFinished={[Function]} onButtonLongPress={[Function]} onButtonPress={[Function]} @@ -2284,7 +2288,7 @@ exports[`Thread should match thread snapshot 1`] = ` ], ] } - tapAnimationInDuration={50} + tapAnimationInDuration={130} tapAnimationOutDuration={100} testID="send-button" testOnly_onPress={[Function]} @@ -2445,8 +2449,10 @@ exports[`Thread should match thread snapshot 1`] = ` } hoverAnimationInDuration={50} hoverAnimationOutDuration={100} + immediatePressOut={false} longPressAnimationOutDuration={100} longPressDuration={500} + moduleId={-1} onButtonInteractionFinished={[Function]} onButtonLongPress={[Function]} onButtonPress={[Function]} @@ -2474,7 +2480,7 @@ exports[`Thread should match thread snapshot 1`] = ` ], ] } - tapAnimationInDuration={50} + tapAnimationInDuration={130} tapAnimationOutDuration={100} testID="upload-photo-touchable" testOnly_onPress={[Function]} @@ -2600,8 +2606,10 @@ exports[`Thread should match thread snapshot 1`] = ` } hoverAnimationInDuration={50} hoverAnimationOutDuration={100} + immediatePressOut={false} longPressAnimationOutDuration={100} longPressDuration={500} + moduleId={-1} onButtonInteractionFinished={[Function]} onButtonLongPress={[Function]} onButtonPress={[Function]} @@ -2629,7 +2637,7 @@ exports[`Thread should match thread snapshot 1`] = ` ], ] } - tapAnimationInDuration={50} + tapAnimationInDuration={130} tapAnimationOutDuration={100} testID="take-photo-touchable" testOnly_onPress={[Function]} @@ -2784,8 +2792,10 @@ exports[`Thread should match thread snapshot 1`] = ` } hoverAnimationInDuration={50} hoverAnimationOutDuration={100} + immediatePressOut={false} longPressAnimationOutDuration={100} longPressDuration={500} + moduleId={-1} onButtonInteractionFinished={[Function]} onButtonLongPress={[Function]} onButtonPress={[Function]} @@ -2813,7 +2823,7 @@ exports[`Thread should match thread snapshot 1`] = ` ], ] } - tapAnimationInDuration={50} + tapAnimationInDuration={130} tapAnimationOutDuration={100} testID="upload-file-touchable" testOnly_onPress={[Function]} @@ -2935,8 +2945,10 @@ exports[`Thread should match thread snapshot 1`] = ` } hoverAnimationInDuration={50} hoverAnimationOutDuration={100} + immediatePressOut={false} longPressAnimationOutDuration={100} longPressDuration={500} + moduleId={-1} onButtonInteractionFinished={[Function]} onButtonLongPress={[Function]} onButtonPress={[Function]} @@ -2964,7 +2976,7 @@ exports[`Thread should match thread snapshot 1`] = ` ], ] } - tapAnimationInDuration={50} + tapAnimationInDuration={130} tapAnimationOutDuration={100} testID="commands-touchable" testOnly_onPress={[Function]} diff --git a/package/src/components/UIComponents/__tests__/SwipableWrapper.test.tsx b/package/src/components/UIComponents/__tests__/SwipableWrapper.test.tsx index c4ffe70ecc..5fa0da0428 100644 --- a/package/src/components/UIComponents/__tests__/SwipableWrapper.test.tsx +++ b/package/src/components/UIComponents/__tests__/SwipableWrapper.test.tsx @@ -1,9 +1,12 @@ import React from 'react'; import { I18nManager, Text, View } from 'react-native'; -import { render } from '@testing-library/react-native'; +import { act, render } from '@testing-library/react-native'; -import { SwipeRegistryProvider } from '../../../contexts/swipeableContext/SwipeRegistryContext'; +import { + SwipeRegistryProvider, + useSwipeRegistryContext, +} from '../../../contexts/swipeableContext/SwipeRegistryContext'; import { SwipableWrapper } from '../SwipableWrapper'; const mockReanimatedSwipeable = jest.fn(({ children }: React.PropsWithChildren) => ( @@ -95,4 +98,67 @@ describe('SwipableWrapper', () => { undefined, ); }); + describe('swipe registry', () => { + type Registry = ReturnType; + + const RegistryProbe = ({ onRegistry }: { onRegistry: (registry: Registry) => void }) => { + onRegistry(useSwipeRegistryContext()); + return null; + }; + + const Rows = ({ ids, onRegistry }: { ids: string[]; onRegistry: (r: Registry) => void }) => ( + + + {ids.map((id) => ( + + {id} + + ))} + + ); + + // The mocked swipeable gets the row's as children, so its label identifies the row. + const openRow = (id: string) => { + const props = mockReanimatedSwipeable.mock.calls + .map(([callProps]) => callProps as React.PropsWithChildren>) + .filter( + (callProps) => + React.isValidElement<{ children: string }>(callProps.children) && + callProps.children.props.children === id, + ) + .pop(); + act(() => { + (props?.onSwipeableOpenStartDrag as (direction: string) => void)('left'); + }); + }; + + it('stops reporting an open swipeable once that swipeable unmounts', () => { + let registry: Registry | undefined; + const onRegistry = (r: Registry) => { + registry = r; + }; + const { rerender } = render(); + + openRow('b'); + expect(registry?.hasOpen()).toBe(true); + + // e.g. a pull-to-refresh that drops the page holding the open row + rerender(); + + expect(registry?.hasOpen()).toBe(false); + }); + + it('keeps another row open when a closed row unmounts', () => { + let registry: Registry | undefined; + const onRegistry = (r: Registry) => { + registry = r; + }; + const { rerender } = render(); + + openRow('a'); + rerender(); + + expect(registry?.hasOpen()).toBe(true); + }); + }); }); diff --git a/package/src/contexts/swipeableContext/SwipeRegistryContext.tsx b/package/src/contexts/swipeableContext/SwipeRegistryContext.tsx index 0829c36c33..b00ce1546c 100644 --- a/package/src/contexts/swipeableContext/SwipeRegistryContext.tsx +++ b/package/src/contexts/swipeableContext/SwipeRegistryContext.tsx @@ -38,6 +38,7 @@ export const SwipeRegistryProvider = ({ children }: PropsWithChildren) => { const registered = swipeablesRef.current?.get(id); if (registered === close) { swipeablesRef.current?.delete(id); + openRef.current?.delete(id); } }; }, []); diff --git a/package/src/middlewares/__tests__/attachments.test.ts b/package/src/middlewares/__tests__/attachments.test.ts index 06b26cb668..b70da8ebb4 100644 --- a/package/src/middlewares/__tests__/attachments.test.ts +++ b/package/src/middlewares/__tests__/attachments.test.ts @@ -63,6 +63,25 @@ describe('createAttachmentsCompositionMiddleware', () => { expect(titles).toEqual(['finished-attachment', 'pending-attachment']); }); + it('keeps attachments that are not uploads as they are', () => { + // e.g. the attachments of an edited message that never went through an upload + const product = { title: 'Camp stove', type: 'product' }; + const giphy = { thumb_url: 'https://media.giphy.com/cat.gif', title: 'cat', type: 'giphy' }; + const { next } = runComposeHandler([ + { ...product, localMetadata: { id: 'product' } }, + createLocalImageAttachment('finished-attachment', 'finished'), + { ...giphy, localMetadata: { id: 'giphy' } }, + ] as LocalAttachment[]); + + const composedState = next.mock.calls[0][0] as { + message: { attachments: { title?: string }[] }; + }; + + expect(composedState.message.attachments[0]).toEqual(product); + expect(composedState.message.attachments[1].title).toBe('finished-attachment'); + expect(composedState.message.attachments[2]).toEqual(giphy); + }); + it('forwards without attachments when every attachment is blocked', () => { const { forward, next } = runComposeHandler([ createLocalImageAttachment('blocked-1', 'blocked'), diff --git a/package/src/middlewares/attachments.ts b/package/src/middlewares/attachments.ts index 671c0a9188..0f81f9dffa 100644 --- a/package/src/middlewares/attachments.ts +++ b/package/src/middlewares/attachments.ts @@ -3,6 +3,7 @@ import { AttachmentPreUploadMiddleware, FileReference, isLocalImageAttachment, + isLocalUploadAttachment, isLocalVideoAttachment, LocalAttachment, MessageComposer, @@ -18,6 +19,10 @@ import { isLocalUrl } from '../utils/utils'; export const localAttachmentToAttachment = (localAttachment: LocalAttachment) => { const { localMetadata, ...attachment } = localAttachment; + // Not an upload (e.g. a custom or giphy attachment of an edited message), so there is no + // local file to point at. + if (!isLocalUploadAttachment(localAttachment)) return attachment as Attachment; + if (isLocalImageAttachment(localAttachment)) { const isRemoteUri = !!attachment.image_url && !isLocalUrl(attachment.image_url); diff --git a/yarn.lock b/yarn.lock index 740ee948e5..89c2493a2f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6971,7 +6971,7 @@ __metadata: react: "npm:19.3.0" react-dom: "npm:19.3.0" react-native: "npm:0.88.0" - react-native-gesture-handler: "npm:~3.3.0" + react-native-gesture-handler: "npm:~3.4.0" react-native-keyboard-controller: "npm:1.22.7" react-native-maps: "npm:1.29.11" react-native-reanimated: "npm:4.7.1" @@ -6981,7 +6981,7 @@ __metadata: react-native-teleport: "npm:^1.2.2" react-native-web: "npm:~0.21.4" react-native-worklets: "npm:0.13.0" - stream-chat: "npm:^9.54.0" + stream-chat: "npm:^9.54.1" stream-chat-expo: "workspace:^" stream-chat-react-native-core: "workspace:^" typescript: "npm:6.0.3" @@ -10749,14 +10749,14 @@ __metadata: expo-router: "npm:~58.0.18" react: "npm:19.3.0" react-native: "npm:0.88.0" - react-native-gesture-handler: "npm:~3.3.0" + react-native-gesture-handler: "npm:~3.4.0" react-native-reanimated: "npm:4.7.1" react-native-safe-area-context: "npm:~5.10.1" react-native-screens: "npm:~4.29.0" react-native-svg: "npm:15.15.5" react-native-teleport: "npm:^1.2.2" react-native-worklets: "npm:0.13.0" - stream-chat: "npm:^9.54.0" + stream-chat: "npm:^9.54.1" stream-chat-expo: "workspace:^" stream-chat-react-native-core: "workspace:^" typescript: "npm:6.0.3" @@ -17258,16 +17258,16 @@ __metadata: languageName: node linkType: hard -"react-native-gesture-handler@npm:^3.3.0, react-native-gesture-handler@npm:~3.3.0": - version: 3.3.0 - resolution: "react-native-gesture-handler@npm:3.3.0" +"react-native-gesture-handler@npm:^3.4.0, react-native-gesture-handler@npm:~3.4.0": + version: 3.4.0 + resolution: "react-native-gesture-handler@npm:3.4.0" dependencies: "@types/react-test-renderer": "npm:^19.1.0" invariant: "npm:^2.2.4" peerDependencies: react: "*" react-native: "*" - checksum: 10c0/6a710722f59eca293e2ff30852cbaf8fb4705712068c9ebb4d1d9d737cda61f175b2b0b452c9fab322d9e14efae3b6d0ab7c884439125b7f5cfb57934f7a65f8 + checksum: 10c0/49fac726e4716347dcbefa7573272d2ec75f8a48eb0721f4fbd71386c10b3f630c339c664a521684425cf03c1b96b7245dd3df93f5262e426bb7a3517e58e8ee languageName: node linkType: hard @@ -18248,7 +18248,7 @@ __metadata: react: "npm:19.3.0" react-native: "npm:0.88.0" react-native-blob-util: "npm:^0.24.12" - react-native-gesture-handler: "npm:^3.3.0" + react-native-gesture-handler: "npm:^3.4.0" react-native-haptic-feedback: "npm:^3.0.0" react-native-image-picker: "npm:^8.2.1" react-native-maps: "npm:^1.29.11" @@ -18262,7 +18262,7 @@ __metadata: react-native-teleport: "npm:^1.2.2" react-native-video: "npm:^6.19.3" react-native-worklets: "npm:^0.13.0" - stream-chat: "npm:^9.54.0" + stream-chat: "npm:^9.54.1" stream-chat-react-native: "workspace:^" stream-chat-react-native-core: "workspace:^" typescript: "npm:6.0.3" @@ -19024,7 +19024,7 @@ __metadata: react: "npm:19.3.0" react-native: "npm:0.88.0" react-native-builder-bob: "npm:0.40.11" - react-native-gesture-handler: "npm:^3.3.0" + react-native-gesture-handler: "npm:^3.4.0" react-native-markdown-package: "npm:1.8.2" react-native-reanimated: "npm:^4.7.1" react-native-safe-area-context: "npm:^5.10.1" @@ -19035,7 +19035,7 @@ __metadata: react-syntax-highlighter: "npm:^16.1.0" react-test-renderer: "npm:19.3.0" rimraf: "npm:^6.0.1" - stream-chat: "npm:^9.54.0" + stream-chat: "npm:^9.54.1" typescript: "npm:6.0.3" use-sync-external-store: "npm:^1.5.0" uuid: "npm:^11.1.0" @@ -19123,9 +19123,9 @@ __metadata: languageName: unknown linkType: soft -"stream-chat@npm:^9.54.0": - version: 9.54.0 - resolution: "stream-chat@npm:9.54.0" +"stream-chat@npm:^9.54.1": + version: 9.54.1 + resolution: "stream-chat@npm:9.54.1" dependencies: "@types/jsonwebtoken": "npm:^9.0.8" "@types/ws": "npm:^8.18.1" @@ -19141,7 +19141,7 @@ __metadata: built: true husky: built: true - checksum: 10c0/f8c02372ffa672e759970223811667b8263bbded048a85adbdd6c3ed60a16f72c882d9700538b94a3148a8e4bfa259eafae5effdc0d8e7ffd2525e61f27a190e + checksum: 10c0/1dd6564a5299f3e9d60a29a6ba6aa8841d94b5622975b71247c7e070e42104f09ef066e44283eb245e20058fb3079881c30bc961dc7e4168ebda0d1dcd66e824 languageName: node linkType: hard