Skip to content

Commit 8c1c667

Browse files
committed
collaborative note editing for public edit notes
1 parent 95f7be0 commit 8c1c667

File tree

14 files changed

+352
-22
lines changed

14 files changed

+352
-22
lines changed

net-api/net-api/Controllers/NotesController.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,9 +131,15 @@ public async Task<IActionResult> PutNote(Guid id, Note note)
131131
}
132132

133133
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
134-
note.UserId = userId;
134+
note.UserId = existingNote.UserId;
135135
note.User = null;
136136

137+
if (userId != existingNote.UserId)
138+
{
139+
note.PublicEdit = existingNote.PublicEdit;
140+
note.PublicView = existingNote.PublicView;
141+
}
142+
137143
_context.Entry(note).State = EntityState.Modified;
138144

139145
try
Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
using Microsoft.AspNetCore.Authorization;
22
using Microsoft.AspNetCore.SignalR;
3+
using Microsoft.EntityFrameworkCore;
34
using Microsoft.IdentityModel.Tokens;
5+
using net_api.Models;
46
using System;
57
using System.IdentityModel.Tokens.Jwt;
8+
using System.Linq;
69
using System.Security.Claims;
710
using System.Security.Cryptography;
811
using System.Threading.Tasks;
@@ -12,20 +15,93 @@ namespace net_api.Controllers
1215
[Authorize]
1316
public class UpdateHub : Hub
1417
{
18+
private readonly IAuthorizationService _auth;
19+
private readonly Context _context;
20+
21+
public UpdateHub(Context context, IAuthorizationService auth)
22+
{
23+
_context = context;
24+
_auth = auth;
25+
}
26+
1527
public async Task Authenticate()
1628
{
1729
await Groups.AddToGroupAsync(Context.ConnectionId, $"notifications-{Context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value}");
1830
await Clients.Caller.SendAsync("AuthenticateComplete");
1931
}
2032

21-
public async Task SubscribeCampaign(string campaignId)
33+
public async Task SubscribeCampaign(Guid campaignId)
2234
{
23-
await Groups.AddToGroupAsync(Context.ConnectionId, $"campaign-{campaignId}");
35+
var campaign = await _context.Campaigns
36+
.Where(c => c.Id == campaignId)
37+
.Include(c => c.Members)
38+
.FirstOrDefaultAsync();
39+
40+
if (campaign == null)
41+
{
42+
return;
43+
}
44+
45+
var authResult = await _auth.AuthorizeAsync(Context.User, campaign, "CampaignViewPolicy");
46+
47+
if (authResult.Succeeded)
48+
{
49+
await Groups.AddToGroupAsync(Context.ConnectionId, $"campaign-{campaignId}");
50+
}
2451
}
2552

2653
public async Task UnsubscribeCampaign(string campaignId)
2754
{
2855
await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"campaign-{campaignId}");
2956
}
57+
58+
public async Task SubscribeNote(Guid? noteId)
59+
{
60+
if (noteId != null)
61+
{
62+
await Groups.AddToGroupAsync(Context.ConnectionId, $"note-{noteId.ToString()}");
63+
}
64+
}
65+
66+
public async Task UnsubscribeNote(Guid? noteId)
67+
{
68+
if (noteId != null)
69+
{
70+
await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"note-{noteId.ToString()}");
71+
}
72+
}
73+
74+
public async Task NoteCursorUpdate(Guid? noteId, CursorUpdate cursorUpdate)
75+
{
76+
if (noteId == null || cursorUpdate == null)
77+
{
78+
return;
79+
}
80+
81+
var userId = Context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
82+
83+
var user = await _context.Users
84+
.Where(u => u.Id == userId)
85+
.FirstOrDefaultAsync();
86+
87+
if (user == null)
88+
{
89+
return;
90+
}
91+
92+
await Clients.GroupExcept($"note-{noteId.ToString()}", Context.ConnectionId)
93+
.SendAsync("NoteCursorUpdate", new { Id = user.Id, DisplayName = user.Username, NoteId = noteId, Range = cursorUpdate.Range });
94+
}
95+
96+
public async Task NoteDeltaUpdate(Guid? noteId, Object delta)
97+
{
98+
if (noteId == null || delta == null)
99+
{
100+
return;
101+
}
102+
103+
await Clients.GroupExcept($"note-{noteId.ToString()}", Context.ConnectionId)
104+
.SendAsync("NoteDeltaUpdate", new { Id = noteId, Delta = delta });
105+
}
30106
}
31107
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Threading.Tasks;
5+
6+
namespace net_api.Models
7+
{
8+
public class CursorUpdate
9+
{
10+
public CursorRange Range { get; set; }
11+
}
12+
13+
public class CursorRange
14+
{
15+
public int Index { get; set; }
16+
public int Length { get; set; }
17+
}
18+
}

ui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"protobufjs": "^6.8.8",
3737
"quill": "^1.3.6",
3838
"quill-blot-formatter": "^1.0.5",
39+
"quill-cursors": "^2.0.3",
3940
"quill-image-uploader": "^1.0.2",
4041
"quill-mention": "^2.1.0",
4142
"rxjs": "~6.4.0",

ui/src/app/login.service.ts

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export class LoginService {
3535
responseType: 'token',
3636
redirectUri: `${location.protocol}//${location.host}/callback`,
3737
audience: 'https://dd.panchem.io',
38+
scope: 'openid',
3839
});
3940
}
4041

@@ -116,10 +117,8 @@ export class LoginService {
116117
resolve(true);
117118
this.onLogin.emit();
118119

119-
// Refresh the login token every 15 minutes as long as the user is on the page
120-
setTimeout(() => {
121-
this.checkSession();
122-
}, 1000 * 60 * 15);
120+
// Get a new token right away, this is done because authentication could have succeeded with an old token
121+
this.checkSession();
123122
} catch (err) {
124123
localStorage.removeItem('auth-token');
125124
resolve(false);
@@ -144,13 +143,32 @@ export class LoginService {
144143
public checkSession() {
145144
const auth = this.getAuth();
146145

147-
auth.checkSession({}, (err, res) => {
148-
if (err) {
149-
Sentry.captureException(err);
150-
} else {
151-
this.saveToken(res.accessToken);
146+
auth.checkSession(
147+
{
148+
domain: environment.auth0Domain,
149+
clientID: environment.auth0ClientId,
150+
responseType: 'token',
151+
redirectUri: `${location.protocol}//${location.host}/callback`,
152+
audience: 'https://dd.panchem.io',
153+
scope: 'openid',
154+
},
155+
(err, res) => {
156+
if (err) {
157+
Sentry.captureException(err);
158+
} else {
159+
console.log('Refreshed Access Token');
160+
161+
this.getUserInfo(res.accessToken)
162+
.then((userInfo) => {
163+
this.saveToken(res.accessToken);
164+
this.authData = userInfo;
165+
})
166+
.catch((err) => {
167+
console.log('Error when updating access token', err);
168+
});
169+
}
152170
}
153-
});
171+
);
154172

155173
// Refresh the token every 15 minutes as long as the user is on the page
156174
setTimeout(() => {

ui/src/app/note/note-editor/note-editor.component.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
*ngIf="note"
99
[note]="note"
1010
[editable]="editable"
11+
[advancedEditable]="advancedEditable"
1112
(noteChange)="onNoteChange($event)"
1213
#noteform
1314
></dd-note-form>

ui/src/app/note/note-editor/note-editor.component.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ export class NoteEditorComponent implements OnInit {
147147
}
148148

149149
public async editNote(note: INote) {
150+
this.statusText = '';
150151
this.modal.open().then(() => {
151152
this.note = undefined;
152153
});
@@ -185,6 +186,10 @@ export class NoteEditorComponent implements OnInit {
185186
return this.note.userId === this.loginService.id || this.note.publicEdit;
186187
}
187188

189+
public get advancedEditable() {
190+
return this.note.userId === this.loginService.id;
191+
}
192+
188193
public get user() {
189194
if (!this.note) {
190195
return null;

ui/src/app/note/note-form/note-form.component.html

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,24 @@
44
simple="true"
55
[readOnly]="!editable"
66
formControlName="content"
7+
(textChange)="textChange($event)"
8+
(selectionChange)="selectionChange($event)"
9+
[cursorUpdates]="cursorUpdates"
10+
[deltaUpdates]="deltaUpdates"
711
></dd-quill>
812
</div>
913

10-
<div class="form-group" *ngIf="editable">
14+
<div class="form-group" *ngIf="advancedEditable">
1115
<label class="form-switch">
1216
<input type="checkbox" formControlName="publicView" />
1317
<i class="form-icon"></i> This note is publicly visible
1418
</label>
1519
</div>
20+
21+
<div class="form-group" *ngIf="advancedEditable">
22+
<label class="form-switch">
23+
<input type="checkbox" formControlName="publicEdit" />
24+
<i class="form-icon"></i> This note is publicly editable
25+
</label>
26+
</div>
1627
</form>

ui/src/app/note/note-form/note-form.component.ts

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,29 @@ import {
44
Input,
55
EventEmitter,
66
Output,
7-
ElementRef,
8-
ViewChild,
9-
AfterViewInit,
7+
OnDestroy,
108
} from '@angular/core';
119
import { FormGroup, FormControl } from '@angular/forms';
1210
import { INote } from 'src/app/note.service';
11+
import { ICursorUpdate } from 'src/app/quill/quill.component';
12+
import { LoginService } from 'src/app/login.service';
13+
import { UpdateHubService, ConnectionState } from 'src/app/update-hub.service';
14+
import { filter } from 'rxjs/operators';
1315

1416
@Component({
1517
selector: 'dd-note-form',
1618
templateUrl: './note-form.component.html',
1719
styleUrls: ['./note-form.component.css'],
1820
})
19-
export class NoteFormComponent implements OnInit {
21+
export class NoteFormComponent implements OnInit, OnDestroy {
2022
public formGroup: FormGroup;
2123

24+
// Events emitted here are passed to the quill instance
25+
public cursorUpdates = new EventEmitter<ICursorUpdate>();
26+
27+
// Events emitted here are passed to the quill instance
28+
public deltaUpdates = new EventEmitter<any>();
29+
2230
@Input()
2331
public set note(note: INote) {
2432
this._note = note;
@@ -40,7 +48,13 @@ export class NoteFormComponent implements OnInit {
4048
@Input()
4149
public editable = false;
4250

43-
constructor() {}
51+
@Input()
52+
public advancedEditable = false;
53+
54+
constructor(
55+
private login: LoginService,
56+
private updateHub: UpdateHubService
57+
) {}
4458

4559
ngOnInit() {
4660
this.formGroup = new FormGroup({
@@ -58,5 +72,42 @@ export class NoteFormComponent implements OnInit {
5872
this.note.content = v.content;
5973
this.noteChange.emit(this.note);
6074
});
75+
76+
this.updateHub.subscribeNote(this.note.id);
77+
78+
this.updateHub.stateUpdate.subscribe((state) => {
79+
if (state === ConnectionState.CONNECTED) {
80+
this.updateHub.subscribeNote(this.note.id);
81+
}
82+
});
83+
84+
this.updateHub.cursorUpdate
85+
.pipe(filter((cu) => cu.noteId === this.note.id))
86+
.subscribe((cu) => {
87+
this.cursorUpdates.emit(cu);
88+
});
89+
90+
this.updateHub.noteDeltaUpdate
91+
.pipe(filter((ndu) => ndu.id === this.note.id))
92+
.subscribe((ndu) => {
93+
this.deltaUpdates.emit(ndu.delta);
94+
});
95+
}
96+
97+
ngOnDestroy() {
98+
this.updateHub.sendNoteCursorUpdate(this.note.id, null);
99+
this.updateHub.unsubscribeNote(this.note.id);
100+
}
101+
102+
public textChange(delta) {
103+
if (this.note.publicEdit) {
104+
this.updateHub.sendNoteDeltaUpdate(this.note.id, delta);
105+
}
106+
}
107+
108+
public selectionChange(range) {
109+
if (this.note.publicEdit) {
110+
this.updateHub.sendNoteCursorUpdate(this.note.id, range);
111+
}
61112
}
62113
}

ui/src/app/note/note-list/note-list.component.html

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,20 @@
44
*ngFor="let n of notes"
55
(click)="noteSelected(n)"
66
>
7-
<div class="columns">
7+
<div class="columns col-gapless">
88
<div class="column">{{ n.title }}</div>
99
<div class="column col-auto text-gray text-italic text-right hide-sm">
1010
{{ getUserText(n.userId) }}
1111
</div>
1212
<div class="column text-gray text-italic show-sm col-12">
1313
{{ getUserText(n.userId) }}
1414
</div>
15+
<div
16+
class="column col-auto d-flex align-items-center ml-1"
17+
*ngIf="n.publicEdit"
18+
>
19+
<i class="icon icon-edit"></i>
20+
</div>
1521
</div>
1622
</div>
1723
</div>

0 commit comments

Comments
 (0)