11/* @flow strict-local */
2+ import * as logging from '../utils/logging' ;
23import * as NavigationService from '../nav/NavigationService' ;
34import type { Narrow , ThunkAction } from '../types' ;
4- import { getAuth } from '../selectors' ;
5+ import { getAuth , getRealm , getMessages , getZulipFeatureLevel } from '../selectors' ;
56import { getNearOperandFromLink , getNarrowFromLink } from '../utils/internalLinks' ;
67import { openLinkWithUserPreference } from '../utils/openLink' ;
78import { navigateToChat } from '../nav/navActions' ;
@@ -10,6 +11,16 @@ import { getStreamsById, getStreamsByName } from '../subscriptions/subscriptionS
1011import * as api from '../api' ;
1112import { isUrlOnRealm } from '../utils/url' ;
1213import { getOwnUserId } from '../users/userSelectors' ;
14+ import {
15+ isTopicNarrow ,
16+ isStreamNarrow ,
17+ topicNarrow ,
18+ streamIdOfNarrow ,
19+ topicOfNarrow ,
20+ streamNarrow ,
21+ caseNarrowDefault ,
22+ } from '../utils/narrow' ;
23+ import { hasMessageEverBeenInStream , hasMessageEverHadTopic } from './messageSelectors' ;
1324
1425/**
1526 * Navigate to the given narrow.
@@ -21,6 +32,171 @@ export const doNarrow =
2132 NavigationService . dispatch ( navigateToChat ( narrow ) ) ;
2233 } ;
2334
35+ /**
36+ * Narrow to a /near/ link, possibly after reinterpreting it for a message move.
37+ *
38+ * It feels quite broken when a link is clearly meant to get you to a
39+ * specific message, but tapping it brings you to a narrow where the message
40+ * *used* to be but isn't anymore because it was moved to a new stream or
41+ * topic. This was #5306.
42+ *
43+ * This action, when it can, recognizes when that's about to happen and
44+ * instead narrows you to the message's current stream/topic.
45+ *
46+ * To do so, it obviously needs to know the message's current stream/topic.
47+ * If those can't be gotten from Redux, we ask the server. If the server
48+ * can't help us (gives an error), we can't help the user, so we won't
49+ * follow a move in that case.
50+ *
51+ * N.B.: Gives a bad experience when the request takes a long time. We
52+ * should fix that; see TODOs.
53+ */
54+ const doNarrowNearLink =
55+ ( narrow : Narrow , nearOperand : number ) : ThunkAction < Promise < void >> =>
56+ async ( dispatch , getState ) => {
57+ const state = getState ( ) ;
58+
59+ const auth = getAuth ( state ) ;
60+ const messages = getMessages ( state ) ;
61+ const zulipFeatureLevel = getZulipFeatureLevel ( state ) ;
62+ const allowEditHistory = getRealm ( state ) . allowEditHistory ;
63+
64+ /**
65+ * Narrow to the /near/ link without reinterpreting it for a message move.
66+ *
67+ * Use this when the link is meant to find the specific message
68+ * identified by nearOperand, and:
69+ * - nearOperand refers to a message that wasn't moved outside the
70+ * narrow specified by the link, or
71+ * - nearOperand *might* refer to a message that was moved, but we don't
72+ * know; we've tried and failed to find out.
73+ *
74+ * Or, use this to insist on the traditional meaning of "near" before
75+ * the message-move feature: take the narrow's stream/topic/etc.
76+ * literally, and open to the message "nearest" the given ID (sent
77+ * around the same time), even if the message with that ID isn't
78+ * actually in the narrow [1].
79+ *
80+ * User docs on moving messages:
81+ * https://zulip.com/help/move-content-to-another-stream
82+ * https://zulip.com/help/move-content-to-another-topic
83+ *
84+ * [1] Tim points out, at
85+ * https://chat.zulip.org/#narrow/stream/101-design/topic/redirects.20from.20near.20links/near/1343095 :
86+ * "[…] useful for situations where you might replace an existing
87+ * search for `stream: 1/topic: 1/near: 15` with
88+ * `stream: 2/topic: 2/near: 15` in order to view what was happening
89+ * in another conversation at the same time as an existing
90+ * conversation."
91+ */
92+ const noMove = ( ) => {
93+ dispatch ( doNarrow ( narrow , nearOperand ) ) ;
94+ } ;
95+
96+ const streamIdOperand =
97+ isStreamNarrow ( narrow ) || isTopicNarrow ( narrow ) ? streamIdOfNarrow ( narrow ) : null ;
98+ const topicOperand = isTopicNarrow ( narrow ) ? topicOfNarrow ( narrow ) : null ;
99+
100+ if ( streamIdOperand === null && topicOperand === null ) {
101+ // Message moves only happen by changing the stream and/or topic.
102+ noMove ( ) ;
103+ return ;
104+ }
105+
106+ // Grab the message and see if it was moved, so we can follow the move
107+ // if so.
108+
109+ // Try to get it from our local data to avoid a server round-trip…
110+ let message = messages . get ( nearOperand ) ;
111+
112+ // …but if we have to, go and ask the server.
113+ // TODO: Give feedback when the server round trip takes longer than
114+ // expected.
115+ // TODO: Let the user cancel the request so we don't force a doNarrow
116+ // after they've given up on tapping the link, and perhaps forgotten
117+ // about it. Like any request, this might take well over a minute to
118+ // resolve, or never resolve.
119+ // TODO: When these are fixed, remove warning in jsdoc.
120+ if ( ! message ) {
121+ // TODO(server-5.0): Simplify.
122+ if ( zulipFeatureLevel < 120 ) {
123+ // api.getSingleMessage won't give us the message's stream and
124+ // topic; see there. Hopefully the message wasn't moved.
125+ noMove ( ) ;
126+ return ;
127+ }
128+ try {
129+ message = await api . getSingleMessage (
130+ auth ,
131+ { message_id : nearOperand } ,
132+ zulipFeatureLevel ,
133+ allowEditHistory ,
134+ ) ;
135+ } catch {
136+ // Hopefully the message, if it exists or ever existed, wasn't moved.
137+ noMove ( ) ;
138+ return ;
139+ }
140+ }
141+
142+ // The FL 120 condition on calling api.getSingleMessage should ensure
143+ // `message` isn't void.
144+ // TODO(server-5.0): Simplify away.
145+ if ( ! message ) {
146+ logging . error ( '`message` from api.getSingleMessage unexpectedly falsy' ) ;
147+ noMove ( ) ;
148+ return ;
149+ }
150+
151+ if ( message . type === 'private' ) {
152+ // A PM could never have been moved.
153+ noMove ( ) ;
154+ return ;
155+ }
156+
157+ if (
158+ ( topicOperand === null || topicOperand === message . subject )
159+ && ( streamIdOperand === null || streamIdOperand === message . stream_id )
160+ ) {
161+ // The message is still in the stream and/or topic in the link.
162+ noMove ( ) ;
163+ return ;
164+ }
165+
166+ if (
167+ ( topicOperand !== null && hasMessageEverHadTopic ( message , topicOperand ) === false )
168+ || ( streamIdOperand !== null && hasMessageEverBeenInStream ( message , streamIdOperand ) === false )
169+ ) {
170+ // The message was never in the narrow specified by the link. That'd
171+ // be an odd link to put in a message…anyway, perhaps we're meant to
172+ // use the traditional meaning of "near"; see noMove's jsdoc
173+ // for what that is.
174+ noMove ( ) ;
175+ return ;
176+ }
177+ // If we couldn't access the edit history in the checks above, assume
178+ // the message was moved. It's the likeliest explanation why its topic
179+ // and/or stream don't match the narrow link.
180+
181+ const { stream_id, subject } = message ;
182+
183+ // Reinterpret the link's narrow with the message's current stream
184+ // and/or topic.
185+ dispatch (
186+ doNarrow (
187+ caseNarrowDefault (
188+ narrow ,
189+ {
190+ stream : ( ) => streamNarrow ( stream_id ) ,
191+ topic : ( ) => topicNarrow ( stream_id , subject ) ,
192+ } ,
193+ ( ) => narrow ,
194+ ) ,
195+ nearOperand ,
196+ ) ,
197+ ) ;
198+ } ;
199+
24200export const messageLinkPress =
25201 ( href : string ) : ThunkAction < Promise < void >> =>
26202 async ( dispatch , getState , { getGlobalSettings } ) => {
@@ -43,7 +219,7 @@ export const messageLinkPress =
43219 return ;
44220 }
45221
46- dispatch ( doNarrow ( narrow , nearOperand ) ) ;
222+ await dispatch ( doNarrowNearLink ( narrow , nearOperand ) ) ;
47223 } else if ( ! isUrlOnRealm ( href , auth . realm ) ) {
48224 openLinkWithUserPreference ( href , getGlobalSettings ( ) ) ;
49225 } else {
0 commit comments