cozy-sharing 33.4.0 → 33.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,19 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [33.4.2](https://github.com/cozy/cozy-libs/compare/cozy-sharing@33.4.1...cozy-sharing@33.4.2) (2026-06-16)
7
+
8
+ ### Bug Fixes
9
+
10
+ - **cozy-sharing:** Preserve sharing updates on rollback ([7c6db1a](https://github.com/cozy/cozy-libs/commit/7c6db1ad63f3997f01bdfee7140f9dad36dde2c2))
11
+ - **sharing:** Update member permissions optimistically ([5c1ac86](https://github.com/cozy/cozy-libs/commit/5c1ac8631e4598ac6d2bc08761d2064c9b396f66))
12
+
13
+ ## [33.4.1](https://github.com/cozy/cozy-libs/compare/cozy-sharing@33.4.0...cozy-sharing@33.4.1) (2026-06-15)
14
+
15
+ ### Bug Fixes
16
+
17
+ - **cozy-sharing:** Keep the owner avatar visible in the share modal ([f63ae1d](https://github.com/cozy/cozy-libs/commit/f63ae1de24de5ca7746b0c6da579f52fa5593806))
18
+
6
19
  # [33.4.0](https://github.com/cozy/cozy-libs/compare/cozy-sharing@33.3.4...cozy-sharing@33.4.0) (2026-06-15)
7
20
 
8
21
  ### Bug Fixes
@@ -471,7 +471,7 @@ export var SharingProvider = /*#__PURE__*/function (_Component) {
471
471
  */
472
472
  _defineProperty(_this2, "updateSharingMemberType", /*#__PURE__*/function () {
473
473
  var _ref12 = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee10(sharingId, memberIndex, newType) {
474
- var sharing, member, makeReadOnly;
474
+ var sharing, member, makeReadOnly, optimisticSharing, currentSharing, rollbackSharing;
475
475
  return _regeneratorRuntime.wrap(function _callee10$(_context10) {
476
476
  while (1) switch (_context10.prev = _context10.next) {
477
477
  case 0:
@@ -505,39 +505,64 @@ export var SharingProvider = /*#__PURE__*/function (_Component) {
505
505
  throw new Error('Unsupported sharing type: ' + newType);
506
506
 
507
507
  case 9:
508
- _context10.prev = 9;
508
+ optimisticSharing = _objectSpread(_objectSpread({}, sharing), {}, {
509
+ attributes: _objectSpread(_objectSpread({}, sharing.attributes), {}, {
510
+ members: sharing.attributes.members.map(function (member, index) {
511
+ return index === memberIndex ? _objectSpread(_objectSpread({}, member), {}, {
512
+ read_only: makeReadOnly
513
+ }) : member;
514
+ })
515
+ })
516
+ });
517
+
518
+ _this2.dispatch(updateSharing(optimisticSharing));
519
+
520
+ _context10.prev = 11;
509
521
 
510
522
  if (!makeReadOnly) {
511
- _context10.next = 15;
523
+ _context10.next = 17;
512
524
  break;
513
525
  }
514
526
 
515
- _context10.next = 13;
527
+ _context10.next = 15;
516
528
  return _this2.sharingCol.setReadOnly(sharing, memberIndex);
517
529
 
518
- case 13:
519
- _context10.next = 17;
530
+ case 15:
531
+ _context10.next = 19;
520
532
  break;
521
533
 
522
- case 15:
523
- _context10.next = 17;
534
+ case 17:
535
+ _context10.next = 19;
524
536
  return _this2.sharingCol.setReadWrite(sharing, memberIndex);
525
537
 
526
- case 17:
527
- _context10.next = 23;
538
+ case 19:
539
+ _context10.next = 28;
528
540
  break;
529
541
 
530
- case 19:
531
- _context10.prev = 19;
532
- _context10.t0 = _context10["catch"](9);
542
+ case 21:
543
+ _context10.prev = 21;
544
+ _context10.t0 = _context10["catch"](11);
545
+ currentSharing = _getSharingById(_this2.state, sharingId) || sharing;
546
+ rollbackSharing = _objectSpread(_objectSpread({}, currentSharing), {}, {
547
+ attributes: _objectSpread(_objectSpread({}, currentSharing.attributes), {}, {
548
+ members: currentSharing.attributes.members.map(function (member, index) {
549
+ return index === memberIndex ? _objectSpread(_objectSpread({}, member), {}, {
550
+ read_only: !makeReadOnly
551
+ }) : member;
552
+ })
553
+ })
554
+ });
555
+
556
+ _this2.dispatch(updateSharing(rollbackSharing));
557
+
533
558
  log.error("Failed to change member ".concat(member.email, " permission type to ").concat(newType), _context10.t0);
534
559
  throw _context10.t0;
535
560
 
536
- case 23:
561
+ case 28:
537
562
  case "end":
538
563
  return _context10.stop();
539
564
  }
540
- }, _callee10, null, [[9, 19]]);
565
+ }, _callee10, null, [[11, 21]]);
541
566
  }));
542
567
 
543
568
  return function (_x16, _x17, _x18) {
@@ -744,7 +744,9 @@ describe('updateSharingMemberType', function () {
744
744
  setReadOnly: jest.fn().mockResolvedValue({}),
745
745
  setReadWrite: jest.fn().mockResolvedValue({})
746
746
  };
747
- jest.spyOn(instance, 'setState');
747
+ instance.dispatch = jest.fn(function (action) {
748
+ instance.state = _objectSpread(_objectSpread({}, instance.state), reducer(instance.state, action));
749
+ });
748
750
  });
749
751
  it('should throw when sharing is not found', /*#__PURE__*/_asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee15() {
750
752
  return _regeneratorRuntime.wrap(function _callee15$(_context15) {
@@ -806,7 +808,7 @@ describe('updateSharingMemberType', function () {
806
808
  }
807
809
  }, _callee18);
808
810
  })));
809
- it('should not dispatch a state update realtime refreshes the store', /*#__PURE__*/_asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee19() {
811
+ it('should optimistically dispatch a state update when switching to one-way', /*#__PURE__*/_asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee19() {
810
812
  return _regeneratorRuntime.wrap(function _callee19$(_context19) {
811
813
  while (1) switch (_context19.prev = _context19.next) {
812
814
  case 0:
@@ -814,7 +816,16 @@ describe('updateSharingMemberType', function () {
814
816
  return instance.updateSharingMemberType('sharing-123', 1, 'one-way');
815
817
 
816
818
  case 2:
817
- expect(instance.setState).not.toHaveBeenCalled();
819
+ expect(instance.dispatch).toHaveBeenCalledWith({
820
+ type: 'UPDATE_SHARING',
821
+ sharing: _objectSpread(_objectSpread({}, mockSharing), {}, {
822
+ attributes: _objectSpread(_objectSpread({}, mockSharing.attributes), {}, {
823
+ members: [mockSharing.attributes.members[0], _objectSpread(_objectSpread({}, mockSharing.attributes.members[1]), {}, {
824
+ read_only: true
825
+ })]
826
+ })
827
+ })
828
+ });
818
829
 
819
830
  case 3:
820
831
  case "end":
@@ -822,7 +833,7 @@ describe('updateSharingMemberType', function () {
822
833
  }
823
834
  }, _callee19);
824
835
  })));
825
- it('should not dispatch a state update for two-way — realtime refreshes the store', /*#__PURE__*/_asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee20() {
836
+ it('should optimistically dispatch a state update when switching to two-way', /*#__PURE__*/_asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee20() {
826
837
  return _regeneratorRuntime.wrap(function _callee20$(_context20) {
827
838
  while (1) switch (_context20.prev = _context20.next) {
828
839
  case 0:
@@ -830,7 +841,16 @@ describe('updateSharingMemberType', function () {
830
841
  return instance.updateSharingMemberType('sharing-123', 1, 'two-way');
831
842
 
832
843
  case 2:
833
- expect(instance.setState).not.toHaveBeenCalled();
844
+ expect(instance.dispatch).toHaveBeenCalledWith({
845
+ type: 'UPDATE_SHARING',
846
+ sharing: _objectSpread(_objectSpread({}, mockSharing), {}, {
847
+ attributes: _objectSpread(_objectSpread({}, mockSharing.attributes), {}, {
848
+ members: [mockSharing.attributes.members[0], _objectSpread(_objectSpread({}, mockSharing.attributes.members[1]), {}, {
849
+ read_only: false
850
+ })]
851
+ })
852
+ })
853
+ });
834
854
 
835
855
  case 3:
836
856
  case "end":
@@ -866,7 +886,7 @@ describe('updateSharingMemberType', function () {
866
886
  }
867
887
  }, _callee22);
868
888
  })));
869
- it('should not dispatch state update if API call fails', /*#__PURE__*/_asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee23() {
889
+ it('should rollback optimistic state update if API call fails', /*#__PURE__*/_asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee23() {
870
890
  return _regeneratorRuntime.wrap(function _callee23$(_context23) {
871
891
  while (1) switch (_context23.prev = _context23.next) {
872
892
  case 0:
@@ -875,14 +895,86 @@ describe('updateSharingMemberType', function () {
875
895
  return expect(instance.updateSharingMemberType('sharing-123', 1, 'one-way')).rejects.toThrow('Network error');
876
896
 
877
897
  case 3:
878
- expect(instance.setState).not.toHaveBeenCalled();
898
+ expect(instance.dispatch).toHaveBeenNthCalledWith(1, {
899
+ type: 'UPDATE_SHARING',
900
+ sharing: _objectSpread(_objectSpread({}, mockSharing), {}, {
901
+ attributes: _objectSpread(_objectSpread({}, mockSharing.attributes), {}, {
902
+ members: [mockSharing.attributes.members[0], _objectSpread(_objectSpread({}, mockSharing.attributes.members[1]), {}, {
903
+ read_only: true
904
+ })]
905
+ })
906
+ })
907
+ });
908
+ expect(instance.dispatch).toHaveBeenNthCalledWith(2, {
909
+ type: 'UPDATE_SHARING',
910
+ sharing: _objectSpread(_objectSpread({}, mockSharing), {}, {
911
+ attributes: _objectSpread(_objectSpread({}, mockSharing.attributes), {}, {
912
+ members: [mockSharing.attributes.members[0], _objectSpread(_objectSpread({}, mockSharing.attributes.members[1]), {}, {
913
+ read_only: false
914
+ })]
915
+ })
916
+ })
917
+ });
879
918
 
880
- case 4:
919
+ case 5:
881
920
  case "end":
882
921
  return _context23.stop();
883
922
  }
884
923
  }, _callee23);
885
924
  })));
925
+ it('should preserve realtime changes when rolling back an API failure', /*#__PURE__*/_asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee25() {
926
+ var realtimeSharing;
927
+ return _regeneratorRuntime.wrap(function _callee25$(_context25) {
928
+ while (1) switch (_context25.prev = _context25.next) {
929
+ case 0:
930
+ realtimeSharing = _objectSpread(_objectSpread({}, mockSharing), {}, {
931
+ attributes: _objectSpread(_objectSpread({}, mockSharing.attributes), {}, {
932
+ description: 'updated by realtime',
933
+ members: [_objectSpread(_objectSpread({}, mockSharing.attributes.members[0]), {}, {
934
+ status: 'seen'
935
+ }), _objectSpread(_objectSpread({}, mockSharing.attributes.members[1]), {}, {
936
+ read_only: true
937
+ })]
938
+ })
939
+ });
940
+ instance.sharingCol.setReadOnly.mockImplementation( /*#__PURE__*/_asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee24() {
941
+ return _regeneratorRuntime.wrap(function _callee24$(_context24) {
942
+ while (1) switch (_context24.prev = _context24.next) {
943
+ case 0:
944
+ instance.state = _objectSpread(_objectSpread({}, instance.state), {}, {
945
+ sharings: instance.state.sharings.map(function (sharing) {
946
+ return sharing.id === realtimeSharing.id ? realtimeSharing : sharing;
947
+ })
948
+ });
949
+ throw new Error('Network error');
950
+
951
+ case 2:
952
+ case "end":
953
+ return _context24.stop();
954
+ }
955
+ }, _callee24);
956
+ })));
957
+ _context25.next = 4;
958
+ return expect(instance.updateSharingMemberType('sharing-123', 1, 'one-way')).rejects.toThrow('Network error');
959
+
960
+ case 4:
961
+ expect(instance.dispatch).toHaveBeenNthCalledWith(2, {
962
+ type: 'UPDATE_SHARING',
963
+ sharing: _objectSpread(_objectSpread({}, realtimeSharing), {}, {
964
+ attributes: _objectSpread(_objectSpread({}, realtimeSharing.attributes), {}, {
965
+ members: [realtimeSharing.attributes.members[0], _objectSpread(_objectSpread({}, realtimeSharing.attributes.members[1]), {}, {
966
+ read_only: false
967
+ })]
968
+ })
969
+ })
970
+ });
971
+
972
+ case 5:
973
+ case "end":
974
+ return _context25.stop();
975
+ }
976
+ }, _callee25);
977
+ })));
886
978
  }); // TODO Convert with react-testing-library
887
979
  // describe('hasWriteAccess', () => {
888
980
  // it('tells if a doc is writtable', () => {
@@ -1,4 +1,5 @@
1
1
  import _defineProperty from "@babel/runtime/helpers/defineProperty";
2
+ import _slicedToArray from "@babel/runtime/helpers/slicedToArray";
2
3
  import _objectWithoutProperties from "@babel/runtime/helpers/objectWithoutProperties";
3
4
  var _excluded = ["recipient"];
4
5
 
@@ -6,7 +7,7 @@ function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (O
6
7
 
7
8
  function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }
8
9
 
9
- import React from 'react';
10
+ import React, { useState } from 'react';
10
11
  import { useClient } from 'cozy-client';
11
12
  import Avatar from 'cozy-ui/transpiled/react/Avatar';
12
13
  import logger from '../../logger';
@@ -16,11 +17,19 @@ var MemberAvatar = function MemberAvatar(_ref) {
16
17
  var recipient = _ref.recipient,
17
18
  rest = _objectWithoutProperties(_ref, _excluded);
18
19
 
19
- var client = useClient(); // There are cases when due to apparent memory leaks, the recipient does not exist
20
+ var client = useClient(); // Remember which image url failed to load so we can fall back to the
21
+ // initials. Storing the url (instead of a boolean) means a new url is
22
+ // retried automatically, without an effect to reset the error state.
23
+
24
+ var _useState = useState(null),
25
+ _useState2 = _slicedToArray(_useState, 2),
26
+ erroredSrc = _useState2[0],
27
+ setErroredSrc = _useState2[1]; // There are cases when due to apparent memory leaks, the recipient does not exist
20
28
  // This will trigger an unhanded error in the Avatar component and crash the app
21
29
  // At the moment, we are not sure where is the root cause of this cascading undefined props
22
30
  // Nevertheless, we can prevent the crash by returning null if the recipient is undefined
23
31
 
32
+
24
33
  if (!recipient) {
25
34
  // eslint-disable-next-line
26
35
  logger.warn('RecipientAvatar: recipient is missing, see props:', _objectSpread({
@@ -40,11 +49,18 @@ var MemberAvatar = function MemberAvatar(_ref) {
40
49
  */
41
50
 
42
51
 
43
- var image = recipient.avatarPath && recipient.status ? "".concat(client.options.uri).concat(recipient.avatarPath, "?v=").concat(recipient.status) : null;
44
- return /*#__PURE__*/React.createElement(Avatar, rest, image ? /*#__PURE__*/React.createElement("img", {
52
+ var image = recipient.avatarPath && recipient.status ? "".concat(client.options.uri).concat(recipient.avatarPath).concat(recipient.avatarPath.includes('?') ? '&' : '?', "v=").concat(recipient.status) : null; // The avatar can become unreachable (eg. the related sharing is revoked
53
+ // while its members are still rendered), in which case the image request
54
+ // fails. Without a fallback the browser shows a broken-image icon, so we
55
+ // render the initials instead.
56
+
57
+ return /*#__PURE__*/React.createElement(Avatar, rest, image && erroredSrc !== image ? /*#__PURE__*/React.createElement("img", {
45
58
  width: "100%",
46
59
  height: "100%",
47
- src: image
60
+ src: image,
61
+ onError: function onError() {
62
+ return setErroredSrc(image);
63
+ }
48
64
  }) : getInitials(recipient));
49
65
  };
50
66
 
@@ -0,0 +1,77 @@
1
+ import { render, fireEvent } from '@testing-library/react';
2
+ import React from 'react';
3
+ import { createMockClient } from 'cozy-client';
4
+ import { MemberAvatar } from './MemberAvatar';
5
+ import AppLike from '../../../test/AppLike';
6
+ describe('MemberAvatar', function () {
7
+ var client = createMockClient({});
8
+ client.options = {
9
+ uri: 'http://cozy.example.com'
10
+ };
11
+
12
+ var setup = function setup(recipient) {
13
+ return render( /*#__PURE__*/React.createElement(AppLike, {
14
+ client: client
15
+ }, /*#__PURE__*/React.createElement(MemberAvatar, {
16
+ recipient: recipient
17
+ })));
18
+ };
19
+
20
+ it('renders nothing when the recipient is missing', function () {
21
+ var _setup = setup(undefined),
22
+ container = _setup.container; // The component returns null; only the provider wrappers remain, with no
23
+ // avatar rendered inside.
24
+
25
+
26
+ expect(container.querySelector('[class*="MuiAvatar-root"]')).toBeNull();
27
+ expect(container.querySelector('img')).toBeNull();
28
+ });
29
+ it('renders the avatar image when the recipient has an avatarPath and a status', function () {
30
+ var _setup2 = setup({
31
+ public_name: 'Bob',
32
+ status: 'owner',
33
+ avatarPath: '/sharings/123/recipients/0/avatar'
34
+ }),
35
+ container = _setup2.container;
36
+
37
+ var img = container.querySelector('img');
38
+ expect(img).toBeTruthy();
39
+ expect(img.getAttribute('src')).toBe('http://cozy.example.com/sharings/123/recipients/0/avatar?v=owner');
40
+ });
41
+ it('appends the cache-busting param with & when avatarPath already has a query string', function () {
42
+ var _setup3 = setup({
43
+ public_name: 'Bob',
44
+ status: 'owner',
45
+ avatarPath: '/public/avatar?fallback=initials'
46
+ }),
47
+ container = _setup3.container;
48
+
49
+ expect(container.querySelector('img').getAttribute('src')).toBe('http://cozy.example.com/public/avatar?fallback=initials&v=owner');
50
+ });
51
+ it('renders the initials when there is no avatarPath', function () {
52
+ var _setup4 = setup({
53
+ public_name: 'Bob',
54
+ status: 'owner'
55
+ }),
56
+ container = _setup4.container,
57
+ getByText = _setup4.getByText;
58
+
59
+ expect(container.querySelector('img')).toBeNull();
60
+ expect(getByText('B')).toBeTruthy();
61
+ });
62
+ it('falls back to the initials when the avatar image fails to load', function () {
63
+ var _setup5 = setup({
64
+ public_name: 'Bob',
65
+ status: 'owner',
66
+ avatarPath: '/sharings/123/recipients/0/avatar'
67
+ }),
68
+ container = _setup5.container,
69
+ getByText = _setup5.getByText;
70
+
71
+ var img = container.querySelector('img');
72
+ expect(img).toBeTruthy();
73
+ fireEvent.error(img);
74
+ expect(container.querySelector('img')).toBeNull();
75
+ expect(getByText('B')).toBeTruthy();
76
+ });
77
+ });
@@ -112,17 +112,18 @@ var MemberRecipientPermissions = function MemberRecipientPermissions(_ref) {
112
112
  return _context2.abrupt("return");
113
113
 
114
114
  case 3:
115
- _context2.prev = 3;
116
- _context2.next = 6;
115
+ hideMenu();
116
+ _context2.prev = 4;
117
+ _context2.next = 7;
117
118
  return updateSharingMemberType(sharingId, memberIndex, newType);
118
119
 
119
- case 6:
120
- _context2.next = 12;
120
+ case 7:
121
+ _context2.next = 13;
121
122
  break;
122
123
 
123
- case 8:
124
- _context2.prev = 8;
125
- _context2.t0 = _context2["catch"](3);
124
+ case 9:
125
+ _context2.prev = 9;
126
+ _context2.t0 = _context2["catch"](4);
126
127
  log.error('Failed to change member permission type', _context2.t0);
127
128
  showAlert({
128
129
  message: t('Share.members.error.changePermission'),
@@ -130,14 +131,11 @@ var MemberRecipientPermissions = function MemberRecipientPermissions(_ref) {
130
131
  variant: 'filled'
131
132
  });
132
133
 
133
- case 12:
134
- hideMenu();
135
-
136
134
  case 13:
137
135
  case "end":
138
136
  return _context2.stop();
139
137
  }
140
- }, _callee2, null, [[3, 8]]);
138
+ }, _callee2, null, [[4, 9]]);
141
139
  }));
142
140
 
143
141
  return function (_x) {
@@ -1,7 +1,13 @@
1
1
  import React from 'react';
2
2
  import { useClient, useQuery, hasQueryBeenLoaded } from 'cozy-client';
3
3
  import MemberRecipient from './MemberRecipient';
4
- import { buildInstanceSettingsQuery } from '../../queries/queries';
4
+ import { buildInstanceSettingsQuery } from '../../queries/queries'; // When the owner is not part of a sharing's members (eg. the folder only has
5
+ // a link), there is no per-sharing avatar to reference. The instance's public
6
+ // avatar still holds the owner's picture. fallback=initials makes the stack
7
+ // serve a generated initials avatar (instead of the app icon) when no picture
8
+ // has been uploaded, matching how avatars are rendered everywhere else.
9
+
10
+ var OWNER_PUBLIC_AVATAR_PATH = '/public/avatar?fallback=initials';
5
11
 
6
12
  var OwnerRecipientDefault = function OwnerRecipientDefault() {
7
13
  var _instanceSettingsResu;
@@ -13,6 +19,7 @@ var OwnerRecipientDefault = function OwnerRecipientDefault() {
13
19
  isOwner: true,
14
20
  status: "owner",
15
21
  instance: client.options.uri,
22
+ avatarPath: OWNER_PUBLIC_AVATAR_PATH,
16
23
  public_name: instanceSettingsResult === null || instanceSettingsResult === void 0 || (_instanceSettingsResu = instanceSettingsResult.data) === null || _instanceSettingsResu === void 0 || (_instanceSettingsResu = _instanceSettingsResu.attributes) === null || _instanceSettingsResu === void 0 ? void 0 : _instanceSettingsResu.public_name
17
24
  });
18
25
  };
@@ -2,7 +2,10 @@ import React from 'react';
2
2
  import { useClient, useQuery, hasQueryBeenLoaded } from 'cozy-client';
3
3
  import MemberRecipientLite from './MemberRecipientLite';
4
4
  import withLocales from '../../hoc/withLocales';
5
- import { buildInstanceSettingsQuery } from '../../queries/queries';
5
+ import { buildInstanceSettingsQuery } from '../../queries/queries'; // See OwnerRecipientDefault: show the instance public avatar (with an initials
6
+ // fallback) when the owner has no per-sharing avatar to reference.
7
+
8
+ var OWNER_PUBLIC_AVATAR_PATH = '/public/avatar?fallback=initials';
6
9
 
7
10
  var OwnerRecipientDefaultLite = function OwnerRecipientDefaultLite() {
8
11
  var _instanceSettingsResu;
@@ -14,6 +17,7 @@ var OwnerRecipientDefaultLite = function OwnerRecipientDefaultLite() {
14
17
  recipient: {
15
18
  status: 'owner',
16
19
  instance: client.options.uri,
20
+ avatarPath: OWNER_PUBLIC_AVATAR_PATH,
17
21
  public_name: instanceSettingsResult === null || instanceSettingsResult === void 0 || (_instanceSettingsResu = instanceSettingsResult.data) === null || _instanceSettingsResu === void 0 || (_instanceSettingsResu = _instanceSettingsResu.attributes) === null || _instanceSettingsResu === void 0 ? void 0 : _instanceSettingsResu.public_name
18
22
  },
19
23
  isOwner: true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cozy-sharing",
3
- "version": "33.4.0",
3
+ "version": "33.4.2",
4
4
  "description": "Provides sharing login for React applications.",
5
5
  "main": "dist/index.js",
6
6
  "author": "Cozy",
@@ -83,5 +83,5 @@
83
83
  "sideEffects": [
84
84
  "*.css"
85
85
  ],
86
- "gitHead": "2359cb47aa065de03f5491ba05114a860a44d9ca"
86
+ "gitHead": "ce6730dfc4d047382e31c566f7e066ad9d7eb195"
87
87
  }
@@ -358,6 +358,24 @@ export class SharingProvider extends Component {
358
358
  if (newType !== 'one-way' && newType !== 'two-way') {
359
359
  throw new Error('Unsupported sharing type: ' + newType)
360
360
  }
361
+
362
+ const optimisticSharing = {
363
+ ...sharing,
364
+ attributes: {
365
+ ...sharing.attributes,
366
+ members: sharing.attributes.members.map((member, index) =>
367
+ index === memberIndex
368
+ ? {
369
+ ...member,
370
+ read_only: makeReadOnly
371
+ }
372
+ : member
373
+ )
374
+ }
375
+ }
376
+
377
+ this.dispatch(updateSharing(optimisticSharing))
378
+
361
379
  try {
362
380
  if (makeReadOnly) {
363
381
  await this.sharingCol.setReadOnly(sharing, memberIndex)
@@ -365,6 +383,23 @@ export class SharingProvider extends Component {
365
383
  await this.sharingCol.setReadWrite(sharing, memberIndex)
366
384
  }
367
385
  } catch (error) {
386
+ const currentSharing = getSharingById(this.state, sharingId) || sharing
387
+ const rollbackSharing = {
388
+ ...currentSharing,
389
+ attributes: {
390
+ ...currentSharing.attributes,
391
+ members: currentSharing.attributes.members.map((member, index) =>
392
+ index === memberIndex
393
+ ? {
394
+ ...member,
395
+ read_only: !makeReadOnly
396
+ }
397
+ : member
398
+ )
399
+ }
400
+ }
401
+
402
+ this.dispatch(updateSharing(rollbackSharing))
368
403
  log.error(
369
404
  `Failed to change member ${member.email} permission type to ${newType}`,
370
405
  error
@@ -524,7 +524,9 @@ describe('updateSharingMemberType', () => {
524
524
  setReadOnly: jest.fn().mockResolvedValue({}),
525
525
  setReadWrite: jest.fn().mockResolvedValue({})
526
526
  }
527
- jest.spyOn(instance, 'setState')
527
+ instance.dispatch = jest.fn(action => {
528
+ instance.state = { ...instance.state, ...reducer(instance.state, action) }
529
+ })
528
530
  })
529
531
 
530
532
  it('should throw when sharing is not found', async () => {
@@ -556,16 +558,46 @@ describe('updateSharingMemberType', () => {
556
558
  expect(instance.sharingCol.setReadOnly).not.toHaveBeenCalled()
557
559
  })
558
560
 
559
- it('should not dispatch a state update realtime refreshes the store', async () => {
561
+ it('should optimistically dispatch a state update when switching to one-way', async () => {
560
562
  await instance.updateSharingMemberType('sharing-123', 1, 'one-way')
561
563
 
562
- expect(instance.setState).not.toHaveBeenCalled()
564
+ expect(instance.dispatch).toHaveBeenCalledWith({
565
+ type: 'UPDATE_SHARING',
566
+ sharing: {
567
+ ...mockSharing,
568
+ attributes: {
569
+ ...mockSharing.attributes,
570
+ members: [
571
+ mockSharing.attributes.members[0],
572
+ {
573
+ ...mockSharing.attributes.members[1],
574
+ read_only: true
575
+ }
576
+ ]
577
+ }
578
+ }
579
+ })
563
580
  })
564
581
 
565
- it('should not dispatch a state update for two-way — realtime refreshes the store', async () => {
582
+ it('should optimistically dispatch a state update when switching to two-way', async () => {
566
583
  await instance.updateSharingMemberType('sharing-123', 1, 'two-way')
567
584
 
568
- expect(instance.setState).not.toHaveBeenCalled()
585
+ expect(instance.dispatch).toHaveBeenCalledWith({
586
+ type: 'UPDATE_SHARING',
587
+ sharing: {
588
+ ...mockSharing,
589
+ attributes: {
590
+ ...mockSharing.attributes,
591
+ members: [
592
+ mockSharing.attributes.members[0],
593
+ {
594
+ ...mockSharing.attributes.members[1],
595
+ read_only: false
596
+ }
597
+ ]
598
+ }
599
+ }
600
+ })
569
601
  })
570
602
 
571
603
  it('should rethrow error if setReadOnly fails', async () => {
@@ -588,7 +620,7 @@ describe('updateSharingMemberType', () => {
588
620
  ).rejects.toThrow('Network error')
589
621
  })
590
622
 
591
- it('should not dispatch state update if API call fails', async () => {
623
+ it('should rollback optimistic state update if API call fails', async () => {
592
624
  instance.sharingCol.setReadOnly.mockRejectedValue(
593
625
  new Error('Network error')
594
626
  )
@@ -597,7 +629,89 @@ describe('updateSharingMemberType', () => {
597
629
  instance.updateSharingMemberType('sharing-123', 1, 'one-way')
598
630
  ).rejects.toThrow('Network error')
599
631
 
600
- expect(instance.setState).not.toHaveBeenCalled()
632
+ expect(instance.dispatch).toHaveBeenNthCalledWith(1, {
633
+ type: 'UPDATE_SHARING',
634
+ sharing: {
635
+ ...mockSharing,
636
+ attributes: {
637
+ ...mockSharing.attributes,
638
+ members: [
639
+ mockSharing.attributes.members[0],
640
+ {
641
+ ...mockSharing.attributes.members[1],
642
+ read_only: true
643
+ }
644
+ ]
645
+ }
646
+ }
647
+ })
648
+ expect(instance.dispatch).toHaveBeenNthCalledWith(2, {
649
+ type: 'UPDATE_SHARING',
650
+ sharing: {
651
+ ...mockSharing,
652
+ attributes: {
653
+ ...mockSharing.attributes,
654
+ members: [
655
+ mockSharing.attributes.members[0],
656
+ {
657
+ ...mockSharing.attributes.members[1],
658
+ read_only: false
659
+ }
660
+ ]
661
+ }
662
+ }
663
+ })
664
+ })
665
+
666
+ it('should preserve realtime changes when rolling back an API failure', async () => {
667
+ const realtimeSharing = {
668
+ ...mockSharing,
669
+ attributes: {
670
+ ...mockSharing.attributes,
671
+ description: 'updated by realtime',
672
+ members: [
673
+ {
674
+ ...mockSharing.attributes.members[0],
675
+ status: 'seen'
676
+ },
677
+ {
678
+ ...mockSharing.attributes.members[1],
679
+ read_only: true
680
+ }
681
+ ]
682
+ }
683
+ }
684
+
685
+ instance.sharingCol.setReadOnly.mockImplementation(async () => {
686
+ instance.state = {
687
+ ...instance.state,
688
+ sharings: instance.state.sharings.map(sharing =>
689
+ sharing.id === realtimeSharing.id ? realtimeSharing : sharing
690
+ )
691
+ }
692
+ throw new Error('Network error')
693
+ })
694
+
695
+ await expect(
696
+ instance.updateSharingMemberType('sharing-123', 1, 'one-way')
697
+ ).rejects.toThrow('Network error')
698
+
699
+ expect(instance.dispatch).toHaveBeenNthCalledWith(2, {
700
+ type: 'UPDATE_SHARING',
701
+ sharing: {
702
+ ...realtimeSharing,
703
+ attributes: {
704
+ ...realtimeSharing.attributes,
705
+ members: [
706
+ realtimeSharing.attributes.members[0],
707
+ {
708
+ ...realtimeSharing.attributes.members[1],
709
+ read_only: false
710
+ }
711
+ ]
712
+ }
713
+ }
714
+ })
601
715
  })
602
716
  })
603
717
 
@@ -1,4 +1,4 @@
1
- import React from 'react'
1
+ import React, { useState } from 'react'
2
2
 
3
3
  import { useClient } from 'cozy-client'
4
4
  import Avatar from 'cozy-ui/transpiled/react/Avatar'
@@ -8,6 +8,10 @@ import { getInitials } from '../../models'
8
8
 
9
9
  const MemberAvatar = ({ recipient, ...rest }) => {
10
10
  const client = useClient()
11
+ // Remember which image url failed to load so we can fall back to the
12
+ // initials. Storing the url (instead of a boolean) means a new url is
13
+ // retried automatically, without an effect to reset the error state.
14
+ const [erroredSrc, setErroredSrc] = useState(null)
11
15
 
12
16
  // There are cases when due to apparent memory leaks, the recipient does not exist
13
17
  // This will trigger an unhanded error in the Avatar component and crash the app
@@ -31,13 +35,24 @@ const MemberAvatar = ({ recipient, ...rest }) => {
31
35
  */
32
36
  const image =
33
37
  recipient.avatarPath && recipient.status
34
- ? `${client.options.uri}${recipient.avatarPath}?v=${recipient.status}`
38
+ ? `${client.options.uri}${recipient.avatarPath}${
39
+ recipient.avatarPath.includes('?') ? '&' : '?'
40
+ }v=${recipient.status}`
35
41
  : null
36
42
 
43
+ // The avatar can become unreachable (eg. the related sharing is revoked
44
+ // while its members are still rendered), in which case the image request
45
+ // fails. Without a fallback the browser shows a broken-image icon, so we
46
+ // render the initials instead.
37
47
  return (
38
48
  <Avatar {...rest}>
39
- {image ? (
40
- <img width="100%" height="100%" src={image} />
49
+ {image && erroredSrc !== image ? (
50
+ <img
51
+ width="100%"
52
+ height="100%"
53
+ src={image}
54
+ onError={() => setErroredSrc(image)}
55
+ />
41
56
  ) : (
42
57
  getInitials(recipient)
43
58
  )}
@@ -0,0 +1,80 @@
1
+ import { render, fireEvent } from '@testing-library/react'
2
+ import React from 'react'
3
+
4
+ import { createMockClient } from 'cozy-client'
5
+
6
+ import { MemberAvatar } from './MemberAvatar'
7
+ import AppLike from '../../../test/AppLike'
8
+
9
+ describe('MemberAvatar', () => {
10
+ const client = createMockClient({})
11
+ client.options = { uri: 'http://cozy.example.com' }
12
+
13
+ const setup = recipient =>
14
+ render(
15
+ <AppLike client={client}>
16
+ <MemberAvatar recipient={recipient} />
17
+ </AppLike>
18
+ )
19
+
20
+ it('renders nothing when the recipient is missing', () => {
21
+ const { container } = setup(undefined)
22
+
23
+ // The component returns null; only the provider wrappers remain, with no
24
+ // avatar rendered inside.
25
+ expect(container.querySelector('[class*="MuiAvatar-root"]')).toBeNull()
26
+ expect(container.querySelector('img')).toBeNull()
27
+ })
28
+
29
+ it('renders the avatar image when the recipient has an avatarPath and a status', () => {
30
+ const { container } = setup({
31
+ public_name: 'Bob',
32
+ status: 'owner',
33
+ avatarPath: '/sharings/123/recipients/0/avatar'
34
+ })
35
+
36
+ const img = container.querySelector('img')
37
+ expect(img).toBeTruthy()
38
+ expect(img.getAttribute('src')).toBe(
39
+ 'http://cozy.example.com/sharings/123/recipients/0/avatar?v=owner'
40
+ )
41
+ })
42
+
43
+ it('appends the cache-busting param with & when avatarPath already has a query string', () => {
44
+ const { container } = setup({
45
+ public_name: 'Bob',
46
+ status: 'owner',
47
+ avatarPath: '/public/avatar?fallback=initials'
48
+ })
49
+
50
+ expect(container.querySelector('img').getAttribute('src')).toBe(
51
+ 'http://cozy.example.com/public/avatar?fallback=initials&v=owner'
52
+ )
53
+ })
54
+
55
+ it('renders the initials when there is no avatarPath', () => {
56
+ const { container, getByText } = setup({
57
+ public_name: 'Bob',
58
+ status: 'owner'
59
+ })
60
+
61
+ expect(container.querySelector('img')).toBeNull()
62
+ expect(getByText('B')).toBeTruthy()
63
+ })
64
+
65
+ it('falls back to the initials when the avatar image fails to load', () => {
66
+ const { container, getByText } = setup({
67
+ public_name: 'Bob',
68
+ status: 'owner',
69
+ avatarPath: '/sharings/123/recipients/0/avatar'
70
+ })
71
+
72
+ const img = container.querySelector('img')
73
+ expect(img).toBeTruthy()
74
+
75
+ fireEvent.error(img)
76
+
77
+ expect(container.querySelector('img')).toBeNull()
78
+ expect(getByText('B')).toBeTruthy()
79
+ })
80
+ })
@@ -74,6 +74,7 @@ const MemberRecipientPermissions = ({
74
74
  hideMenu()
75
75
  return
76
76
  }
77
+ hideMenu()
77
78
  try {
78
79
  await updateSharingMemberType(sharingId, memberIndex, newType)
79
80
  } catch (error) {
@@ -84,7 +85,6 @@ const MemberRecipientPermissions = ({
84
85
  variant: 'filled'
85
86
  })
86
87
  }
87
- hideMenu()
88
88
  },
89
89
  [
90
90
  hideMenu,
@@ -5,6 +5,13 @@ import { useClient, useQuery, hasQueryBeenLoaded } from 'cozy-client'
5
5
  import MemberRecipient from './MemberRecipient'
6
6
  import { buildInstanceSettingsQuery } from '../../queries/queries'
7
7
 
8
+ // When the owner is not part of a sharing's members (eg. the folder only has
9
+ // a link), there is no per-sharing avatar to reference. The instance's public
10
+ // avatar still holds the owner's picture. fallback=initials makes the stack
11
+ // serve a generated initials avatar (instead of the app icon) when no picture
12
+ // has been uploaded, matching how avatars are rendered everywhere else.
13
+ const OWNER_PUBLIC_AVATAR_PATH = '/public/avatar?fallback=initials'
14
+
8
15
  const OwnerRecipientDefault = () => {
9
16
  const client = useClient()
10
17
 
@@ -20,6 +27,7 @@ const OwnerRecipientDefault = () => {
20
27
  isOwner={true}
21
28
  status="owner"
22
29
  instance={client.options.uri}
30
+ avatarPath={OWNER_PUBLIC_AVATAR_PATH}
23
31
  public_name={instanceSettingsResult?.data?.attributes?.public_name}
24
32
  />
25
33
  )
@@ -6,6 +6,10 @@ import MemberRecipientLite from './MemberRecipientLite'
6
6
  import withLocales from '../../hoc/withLocales'
7
7
  import { buildInstanceSettingsQuery } from '../../queries/queries'
8
8
 
9
+ // See OwnerRecipientDefault: show the instance public avatar (with an initials
10
+ // fallback) when the owner has no per-sharing avatar to reference.
11
+ const OWNER_PUBLIC_AVATAR_PATH = '/public/avatar?fallback=initials'
12
+
9
13
  const OwnerRecipientDefaultLite = () => {
10
14
  const client = useClient()
11
15
 
@@ -21,6 +25,7 @@ const OwnerRecipientDefaultLite = () => {
21
25
  recipient={{
22
26
  status: 'owner',
23
27
  instance: client.options.uri,
28
+ avatarPath: OWNER_PUBLIC_AVATAR_PATH,
24
29
  public_name: instanceSettingsResult?.data?.attributes?.public_name
25
30
  }}
26
31
  isOwner={true}