Fix link targets
[bloat] / service / service.go
1 package service
2
3 import (
4         "context"
5         "errors"
6         "fmt"
7         "html/template"
8         "mime/multipart"
9         "net/url"
10         "strings"
11
12         "bloat/mastodon"
13         "bloat/model"
14         "bloat/renderer"
15         "bloat/util"
16 )
17
18 var (
19         ctx                = context.Background()
20         errInvalidArgument = errors.New("invalid argument")
21 )
22
23 type Service interface {
24         ServeErrorPage(c *model.Client, err error)
25         ServeSigninPage(c *model.Client) (err error)
26         ServeRootPage(c *model.Client) (err error)
27         ServeNavPage(c *model.Client) (err error)
28         ServeTimelinePage(c *model.Client, tType string, maxID string,
29                 minID string) (err error)
30         ServeThreadPage(c *model.Client, id string, reply bool) (err error)
31         ServeLikedByPage(c *model.Client, id string) (err error)
32         ServeRetweetedByPage(c *model.Client, id string) (err error)
33         ServeNotificationPage(c *model.Client, maxID string, minID string) (err error)
34         ServeUserPage(c *model.Client, id string, pageType string, maxID string,
35                 minID string) (err error)
36         ServeAboutPage(c *model.Client) (err error)
37         ServeEmojiPage(c *model.Client) (err error)
38         ServeSearchPage(c *model.Client, q string, qType string, offset int) (err error)
39         ServeUserSearchPage(c *model.Client, id string, q string, offset int) (err error)
40         ServeSettingsPage(c *model.Client) (err error)
41         SingleInstance() (instance string, ok bool)
42         NewSession(instance string) (redirectUrl string, sessionID string, err error)
43         Signin(c *model.Client, sessionID string, code string) (token string,
44                 userID string, err error)
45         Signout(c *model.Client) (err error)
46         Post(c *model.Client, content string, replyToID string, format string, visibility string,
47                 isNSFW bool, files []*multipart.FileHeader) (id string, err error)
48         Like(c *model.Client, id string) (count int64, err error)
49         UnLike(c *model.Client, id string) (count int64, err error)
50         Retweet(c *model.Client, id string) (count int64, err error)
51         UnRetweet(c *model.Client, id string) (count int64, err error)
52         Vote(c *model.Client, id string, choices []string) (err error)
53         Follow(c *model.Client, id string, reblogs *bool) (err error)
54         UnFollow(c *model.Client, id string) (err error)
55         Mute(c *model.Client, id string) (err error)
56         UnMute(c *model.Client, id string) (err error)
57         Block(c *model.Client, id string) (err error)
58         UnBlock(c *model.Client, id string) (err error)
59         Subscribe(c *model.Client, id string) (err error)
60         UnSubscribe(c *model.Client, id string) (err error)
61         SaveSettings(c *model.Client, settings *model.Settings) (err error)
62         MuteConversation(c *model.Client, id string) (err error)
63         UnMuteConversation(c *model.Client, id string) (err error)
64         Delete(c *model.Client, id string) (err error)
65         ReadNotifications(c *model.Client, maxID string) (err error)
66         Bookmark(c *model.Client, id string) (err error)
67         UnBookmark(c *model.Client, id string) (err error)
68 }
69
70 type service struct {
71         clientName     string
72         clientScope    string
73         clientWebsite  string
74         customCSS      string
75         postFormats    []model.PostFormat
76         renderer       renderer.Renderer
77         sessionRepo    model.SessionRepo
78         appRepo        model.AppRepo
79         singleInstance string
80 }
81
82 func NewService(clientName string,
83         clientScope string,
84         clientWebsite string,
85         customCSS string,
86         postFormats []model.PostFormat,
87         renderer renderer.Renderer,
88         sessionRepo model.SessionRepo,
89         appRepo model.AppRepo,
90         singleInstance string,
91 ) Service {
92         return &service{
93                 clientName:     clientName,
94                 clientScope:    clientScope,
95                 clientWebsite:  clientWebsite,
96                 customCSS:      customCSS,
97                 postFormats:    postFormats,
98                 renderer:       renderer,
99                 sessionRepo:    sessionRepo,
100                 appRepo:        appRepo,
101                 singleInstance: singleInstance,
102         }
103 }
104
105 func getRendererContext(c *model.Client) *renderer.Context {
106         var settings model.Settings
107         var session model.Session
108         if c != nil {
109                 settings = c.Session.Settings
110                 session = c.Session
111         } else {
112                 settings = *model.NewSettings()
113         }
114         return &renderer.Context{
115                 HideAttachments:  settings.HideAttachments,
116                 MaskNSFW:         settings.MaskNSFW,
117                 ThreadInNewTab:   settings.ThreadInNewTab,
118                 FluorideMode:     settings.FluorideMode,
119                 DarkMode:         settings.DarkMode,
120                 CSRFToken:        session.CSRFToken,
121                 UserID:           session.UserID,
122                 AntiDopamineMode: settings.AntiDopamineMode,
123         }
124 }
125
126 func addToReplyMap(m map[string][]mastodon.ReplyInfo, key interface{},
127         val string, number int) {
128         if key == nil {
129                 return
130         }
131
132         keyStr, ok := key.(string)
133         if !ok {
134                 return
135         }
136
137         _, ok = m[keyStr]
138         if !ok {
139                 m[keyStr] = []mastodon.ReplyInfo{}
140         }
141
142         m[keyStr] = append(m[keyStr], mastodon.ReplyInfo{val, number})
143 }
144
145 func (svc *service) getCommonData(c *model.Client,
146         title string) (data *renderer.CommonData) {
147
148         data = &renderer.CommonData{
149                 Title:     title + " - " + svc.clientName,
150                 CustomCSS: svc.customCSS,
151         }
152         if c != nil && c.Session.IsLoggedIn() {
153                 data.CSRFToken = c.Session.CSRFToken
154         }
155         return
156 }
157
158 func (svc *service) ServeErrorPage(c *model.Client, err error) {
159         var errStr string
160         if err != nil {
161                 errStr = err.Error()
162         }
163
164         commonData := svc.getCommonData(nil, "error")
165         data := &renderer.ErrorData{
166                 CommonData: commonData,
167                 Error:      errStr,
168         }
169
170         rCtx := getRendererContext(c)
171         svc.renderer.Render(rCtx, c.Writer, renderer.ErrorPage, data)
172 }
173
174 func (svc *service) ServeSigninPage(c *model.Client) (err error) {
175         commonData := svc.getCommonData(nil, "signin")
176         data := &renderer.SigninData{
177                 CommonData: commonData,
178         }
179
180         rCtx := getRendererContext(nil)
181         return svc.renderer.Render(rCtx, c.Writer, renderer.SigninPage, data)
182 }
183
184 func (svc *service) ServeRootPage(c *model.Client) (err error) {
185         data := &renderer.RootData{
186                 Title: svc.clientName,
187         }
188
189         rCtx := getRendererContext(c)
190         return svc.renderer.Render(rCtx, c.Writer, renderer.RootPage, data)
191 }
192
193 func (svc *service) ServeNavPage(c *model.Client) (err error) {
194         u, err := c.GetAccountCurrentUser(ctx)
195         if err != nil {
196                 return
197         }
198
199         postContext := model.PostContext{
200                 DefaultVisibility: c.Session.Settings.DefaultVisibility,
201                 DefaultFormat:     c.Session.Settings.DefaultFormat,
202                 Formats:           svc.postFormats,
203         }
204
205         commonData := svc.getCommonData(c, "Nav")
206         commonData.Target = "main"
207         data := &renderer.NavData{
208                 User:        u,
209                 CommonData:  commonData,
210                 PostContext: postContext,
211         }
212
213         rCtx := getRendererContext(c)
214         return svc.renderer.Render(rCtx, c.Writer, renderer.NavPage, data)
215 }
216
217 func (svc *service) ServeTimelinePage(c *model.Client, tType string,
218         maxID string, minID string) (err error) {
219
220         var nextLink, prevLink, title string
221         var statuses []*mastodon.Status
222         var pg = mastodon.Pagination{
223                 MaxID: maxID,
224                 MinID: minID,
225                 Limit: 20,
226         }
227
228         switch tType {
229         default:
230                 return errInvalidArgument
231         case "home":
232                 statuses, err = c.GetTimelineHome(ctx, &pg)
233                 title = "Timeline"
234         case "direct":
235                 statuses, err = c.GetTimelineDirect(ctx, &pg)
236                 title = "Direct Timeline"
237         case "local":
238                 statuses, err = c.GetTimelinePublic(ctx, true, &pg)
239                 title = "Local Timeline"
240         case "twkn":
241                 statuses, err = c.GetTimelinePublic(ctx, false, &pg)
242                 title = "The Whole Known Network"
243         }
244         if err != nil {
245                 return err
246         }
247
248         for i := range statuses {
249                 if statuses[i].Reblog != nil {
250                         statuses[i].Reblog.RetweetedByID = statuses[i].ID
251                 }
252         }
253
254         if len(maxID) > 0 && len(statuses) > 0 {
255                 prevLink = fmt.Sprintf("/timeline/%s?min_id=%s", tType,
256                         statuses[0].ID)
257         }
258
259         if len(minID) > 0 && len(pg.MinID) > 0 {
260                 newPg := &mastodon.Pagination{MinID: pg.MinID, Limit: 20}
261                 newStatuses, err := c.GetTimelineHome(ctx, newPg)
262                 if err != nil {
263                         return err
264                 }
265                 newLen := len(newStatuses)
266                 if newLen == 20 {
267                         prevLink = fmt.Sprintf("/timeline/%s?min_id=%s",
268                                 tType, pg.MinID)
269                 } else {
270                         i := 20 - newLen - 1
271                         if len(statuses) > i {
272                                 prevLink = fmt.Sprintf("/timeline/%s?min_id=%s",
273                                         tType, statuses[i].ID)
274                         }
275                 }
276         }
277
278         if len(pg.MaxID) > 0 && len(statuses) == 20 {
279                 nextLink = fmt.Sprintf("/timeline/%s?max_id=%s", tType, pg.MaxID)
280         }
281
282         commonData := svc.getCommonData(c, tType+" timeline ")
283         data := &renderer.TimelineData{
284                 Title:      title,
285                 Statuses:   statuses,
286                 NextLink:   nextLink,
287                 PrevLink:   prevLink,
288                 CommonData: commonData,
289         }
290
291         rCtx := getRendererContext(c)
292         return svc.renderer.Render(rCtx, c.Writer, renderer.TimelinePage, data)
293 }
294
295 func (svc *service) ServeThreadPage(c *model.Client, id string, reply bool) (err error) {
296         var postContext model.PostContext
297
298         status, err := c.GetStatus(ctx, id)
299         if err != nil {
300                 return
301         }
302
303         if reply {
304                 var content string
305                 var visibility string
306                 if c.Session.UserID != status.Account.ID {
307                         content += "@" + status.Account.Acct + " "
308                 }
309                 for i := range status.Mentions {
310                         if status.Mentions[i].ID != c.Session.UserID &&
311                                 status.Mentions[i].ID != status.Account.ID {
312                                 content += "@" + status.Mentions[i].Acct + " "
313                         }
314                 }
315
316                 isDirect := status.Visibility == "direct"
317                 if isDirect || c.Session.Settings.CopyScope {
318                         visibility = status.Visibility
319                 } else {
320                         visibility = c.Session.Settings.DefaultVisibility
321                 }
322
323                 postContext = model.PostContext{
324                         DefaultVisibility: visibility,
325                         DefaultFormat:     c.Session.Settings.DefaultFormat,
326                         Formats:           svc.postFormats,
327                         ReplyContext: &model.ReplyContext{
328                                 InReplyToID:     id,
329                                 InReplyToName:   status.Account.Acct,
330                                 ReplyContent:    content,
331                                 ForceVisibility: isDirect,
332                         },
333                         DarkMode: c.Session.Settings.DarkMode,
334                 }
335         }
336
337         context, err := c.GetStatusContext(ctx, id)
338         if err != nil {
339                 return
340         }
341
342         statuses := append(append(context.Ancestors, status), context.Descendants...)
343         replies := make(map[string][]mastodon.ReplyInfo)
344         idNumbers := make(map[string]int)
345
346         for i := range statuses {
347                 statuses[i].ShowReplies = true
348
349                 statuses[i].IDNumbers = idNumbers
350                 idNumbers[statuses[i].ID] = i + 1
351
352                 statuses[i].IDReplies = replies
353                 addToReplyMap(replies, statuses[i].InReplyToID, statuses[i].ID, i+1)
354         }
355
356         commonData := svc.getCommonData(c, "post by "+status.Account.DisplayName)
357         data := &renderer.ThreadData{
358                 Statuses:    statuses,
359                 PostContext: postContext,
360                 ReplyMap:    replies,
361                 CommonData:  commonData,
362         }
363
364         rCtx := getRendererContext(c)
365         return svc.renderer.Render(rCtx, c.Writer, renderer.ThreadPage, data)
366 }
367
368 func (svc *service) ServeLikedByPage(c *model.Client, id string) (err error) {
369         likers, err := c.GetFavouritedBy(ctx, id, nil)
370         if err != nil {
371                 return
372         }
373
374         commonData := svc.getCommonData(c, "likes")
375         data := &renderer.LikedByData{
376                 CommonData: commonData,
377                 Users:      likers,
378         }
379
380         rCtx := getRendererContext(c)
381         return svc.renderer.Render(rCtx, c.Writer, renderer.LikedByPage, data)
382 }
383
384 func (svc *service) ServeRetweetedByPage(c *model.Client, id string) (err error) {
385         retweeters, err := c.GetRebloggedBy(ctx, id, nil)
386         if err != nil {
387                 return
388         }
389
390         commonData := svc.getCommonData(c, "retweets")
391         data := &renderer.RetweetedByData{
392                 CommonData: commonData,
393                 Users:      retweeters,
394         }
395
396         rCtx := getRendererContext(c)
397         return svc.renderer.Render(rCtx, c.Writer, renderer.RetweetedByPage, data)
398 }
399
400 func (svc *service) ServeNotificationPage(c *model.Client, maxID string,
401         minID string) (err error) {
402
403         var nextLink string
404         var unreadCount int
405         var readID string
406         var excludes []string
407         var pg = mastodon.Pagination{
408                 MaxID: maxID,
409                 MinID: minID,
410                 Limit: 20,
411         }
412
413         if c.Session.Settings.AntiDopamineMode {
414                 excludes = []string{"follow", "favourite", "reblog"}
415         }
416
417         notifications, err := c.GetNotifications(ctx, &pg, excludes)
418         if err != nil {
419                 return
420         }
421
422         for i := range notifications {
423                 if notifications[i].Pleroma != nil && !notifications[i].Pleroma.IsSeen {
424                         unreadCount++
425                 }
426         }
427
428         if unreadCount > 0 {
429                 readID = notifications[0].ID
430         }
431
432         if len(notifications) == 20 && len(pg.MaxID) > 0 {
433                 nextLink = "/notifications?max_id=" + pg.MaxID
434         }
435
436         commonData := svc.getCommonData(c, "notifications")
437         commonData.RefreshInterval = c.Session.Settings.NotificationInterval
438         commonData.Target = "main"
439         commonData.Count = unreadCount
440         data := &renderer.NotificationData{
441                 Notifications: notifications,
442                 UnreadCount:   unreadCount,
443                 ReadID:        readID,
444                 NextLink:      nextLink,
445                 CommonData:    commonData,
446         }
447         rCtx := getRendererContext(c)
448         return svc.renderer.Render(rCtx, c.Writer, renderer.NotificationPage, data)
449 }
450
451 func (svc *service) ServeUserPage(c *model.Client, id string, pageType string,
452         maxID string, minID string) (err error) {
453
454         var nextLink string
455         var statuses []*mastodon.Status
456         var users []*mastodon.Account
457         var pg = mastodon.Pagination{
458                 MaxID: maxID,
459                 MinID: minID,
460                 Limit: 20,
461         }
462
463         user, err := c.GetAccount(ctx, id)
464         if err != nil {
465                 return
466         }
467         isCurrent := c.Session.UserID == user.ID
468
469         switch pageType {
470         case "":
471                 statuses, err = c.GetAccountStatuses(ctx, id, false, &pg)
472                 if err != nil {
473                         return
474                 }
475                 if len(statuses) == 20 && len(pg.MaxID) > 0 {
476                         nextLink = fmt.Sprintf("/user/%s?max_id=%s", id,
477                                 pg.MaxID)
478                 }
479         case "following":
480                 users, err = c.GetAccountFollowing(ctx, id, &pg)
481                 if err != nil {
482                         return
483                 }
484                 if len(users) == 20 && len(pg.MaxID) > 0 {
485                         nextLink = fmt.Sprintf("/user/%s/following?max_id=%s",
486                                 id, pg.MaxID)
487                 }
488         case "followers":
489                 users, err = c.GetAccountFollowers(ctx, id, &pg)
490                 if err != nil {
491                         return
492                 }
493                 if len(users) == 20 && len(pg.MaxID) > 0 {
494                         nextLink = fmt.Sprintf("/user/%s/followers?max_id=%s",
495                                 id, pg.MaxID)
496                 }
497         case "media":
498                 statuses, err = c.GetAccountStatuses(ctx, id, true, &pg)
499                 if err != nil {
500                         return
501                 }
502                 if len(statuses) == 20 && len(pg.MaxID) > 0 {
503                         nextLink = fmt.Sprintf("/user/%s/media?max_id=%s",
504                                 id, pg.MaxID)
505                 }
506         case "bookmarks":
507                 if !isCurrent {
508                         return errInvalidArgument
509                 }
510                 statuses, err = c.GetBookmarks(ctx, &pg)
511                 if err != nil {
512                         return
513                 }
514                 if len(statuses) == 20 && len(pg.MaxID) > 0 {
515                         nextLink = fmt.Sprintf("/user/%s/bookmarks?max_id=%s",
516                                 id, pg.MaxID)
517                 }
518         case "mutes":
519                 if !isCurrent {
520                         return errInvalidArgument
521                 }
522                 users, err = c.GetMutes(ctx, &pg)
523                 if err != nil {
524                         return
525                 }
526                 if len(users) == 20 && len(pg.MaxID) > 0 {
527                         nextLink = fmt.Sprintf("/user/%s/mutes?max_id=%s",
528                                 id, pg.MaxID)
529                 }
530         case "blocks":
531                 if !isCurrent {
532                         return errInvalidArgument
533                 }
534                 users, err = c.GetBlocks(ctx, &pg)
535                 if err != nil {
536                         return
537                 }
538                 if len(users) == 20 && len(pg.MaxID) > 0 {
539                         nextLink = fmt.Sprintf("/user/%s/blocks?max_id=%s",
540                                 id, pg.MaxID)
541                 }
542         case "likes":
543                 if !isCurrent {
544                         return errInvalidArgument
545                 }
546                 statuses, err = c.GetFavourites(ctx, &pg)
547                 if err != nil {
548                         return
549                 }
550                 if len(statuses) == 20 && len(pg.MaxID) > 0 {
551                         nextLink = fmt.Sprintf("/user/%s/likes?max_id=%s",
552                                 id, pg.MaxID)
553                 }
554         default:
555                 return errInvalidArgument
556         }
557
558         for i := range statuses {
559                 if statuses[i].Reblog != nil {
560                         statuses[i].Reblog.RetweetedByID = statuses[i].ID
561                 }
562         }
563
564         commonData := svc.getCommonData(c, user.DisplayName)
565         data := &renderer.UserData{
566                 User:       user,
567                 IsCurrent:  isCurrent,
568                 Type:       pageType,
569                 Users:      users,
570                 Statuses:   statuses,
571                 NextLink:   nextLink,
572                 CommonData: commonData,
573         }
574         rCtx := getRendererContext(c)
575         return svc.renderer.Render(rCtx, c.Writer, renderer.UserPage, data)
576 }
577
578 func (svc *service) ServeUserSearchPage(c *model.Client,
579         id string, q string, offset int) (err error) {
580
581         var nextLink string
582         var title = "search"
583
584         user, err := c.GetAccount(ctx, id)
585         if err != nil {
586                 return
587         }
588
589         var results *mastodon.Results
590         if len(q) > 0 {
591                 results, err = c.Search(ctx, q, "statuses", 20, true, offset, id)
592                 if err != nil {
593                         return err
594                 }
595         } else {
596                 results = &mastodon.Results{}
597         }
598
599         if len(results.Statuses) == 20 {
600                 offset += 20
601                 nextLink = fmt.Sprintf("/usersearch/%s?q=%s&offset=%d", id, url.QueryEscape(q), offset)
602         }
603
604         qq := template.HTMLEscapeString(q)
605         if len(q) > 0 {
606                 title += " \"" + qq + "\""
607         }
608
609         commonData := svc.getCommonData(c, title)
610         data := &renderer.UserSearchData{
611                 CommonData: commonData,
612                 User:       user,
613                 Q:          qq,
614                 Statuses:   results.Statuses,
615                 NextLink:   nextLink,
616         }
617
618         rCtx := getRendererContext(c)
619         return svc.renderer.Render(rCtx, c.Writer, renderer.UserSearchPage, data)
620 }
621
622 func (svc *service) ServeAboutPage(c *model.Client) (err error) {
623         commonData := svc.getCommonData(c, "about")
624         data := &renderer.AboutData{
625                 CommonData: commonData,
626         }
627
628         rCtx := getRendererContext(c)
629         return svc.renderer.Render(rCtx, c.Writer, renderer.AboutPage, data)
630 }
631
632 func (svc *service) ServeEmojiPage(c *model.Client) (err error) {
633         emojis, err := c.GetInstanceEmojis(ctx)
634         if err != nil {
635                 return
636         }
637
638         commonData := svc.getCommonData(c, "emojis")
639         data := &renderer.EmojiData{
640                 Emojis:     emojis,
641                 CommonData: commonData,
642         }
643
644         rCtx := getRendererContext(c)
645         return svc.renderer.Render(rCtx, c.Writer, renderer.EmojiPage, data)
646 }
647
648 func (svc *service) ServeSearchPage(c *model.Client,
649         q string, qType string, offset int) (err error) {
650
651         var nextLink string
652         var title = "search"
653
654         var results *mastodon.Results
655         if len(q) > 0 {
656                 results, err = c.Search(ctx, q, qType, 20, true, offset, "")
657                 if err != nil {
658                         return err
659                 }
660         } else {
661                 results = &mastodon.Results{}
662         }
663
664         if (qType == "accounts" && len(results.Accounts) == 20) ||
665                 (qType == "statuses" && len(results.Statuses) == 20) {
666                 offset += 20
667                 nextLink = fmt.Sprintf("/search?q=%s&type=%s&offset=%d", url.QueryEscape(q), qType, offset)
668         }
669
670         qq := template.HTMLEscapeString(q)
671         if len(q) > 0 {
672                 title += " \"" + qq + "\""
673         }
674
675         commonData := svc.getCommonData(c, title)
676         data := &renderer.SearchData{
677                 CommonData: commonData,
678                 Q:          qq,
679                 Type:       qType,
680                 Users:      results.Accounts,
681                 Statuses:   results.Statuses,
682                 NextLink:   nextLink,
683         }
684
685         rCtx := getRendererContext(c)
686         return svc.renderer.Render(rCtx, c.Writer, renderer.SearchPage, data)
687 }
688
689 func (svc *service) ServeSettingsPage(c *model.Client) (err error) {
690         commonData := svc.getCommonData(c, "settings")
691         data := &renderer.SettingsData{
692                 CommonData:  commonData,
693                 Settings:    &c.Session.Settings,
694                 PostFormats: svc.postFormats,
695         }
696
697         rCtx := getRendererContext(c)
698         return svc.renderer.Render(rCtx, c.Writer, renderer.SettingsPage, data)
699 }
700
701 func (svc *service) SingleInstance() (instance string, ok bool) {
702         if len(svc.singleInstance) > 0 {
703                 instance = svc.singleInstance
704                 ok = true
705         }
706         return
707 }
708
709 func (svc *service) NewSession(instance string) (
710         redirectUrl string, sessionID string, err error) {
711
712         var instanceURL string
713         if strings.HasPrefix(instance, "https://") {
714                 instanceURL = instance
715                 instance = strings.TrimPrefix(instance, "https://")
716         } else {
717                 instanceURL = "https://" + instance
718         }
719
720         sessionID, err = util.NewSessionID()
721         if err != nil {
722                 return
723         }
724
725         csrfToken, err := util.NewCSRFToken()
726         if err != nil {
727                 return
728         }
729
730         session := model.Session{
731                 ID:             sessionID,
732                 InstanceDomain: instance,
733                 CSRFToken:      csrfToken,
734                 Settings:       *model.NewSettings(),
735         }
736
737         err = svc.sessionRepo.Add(session)
738         if err != nil {
739                 return
740         }
741
742         app, err := svc.appRepo.Get(instance)
743         if err != nil {
744                 if err != model.ErrAppNotFound {
745                         return
746                 }
747
748                 mastoApp, err := mastodon.RegisterApp(ctx, &mastodon.AppConfig{
749                         Server:       instanceURL,
750                         ClientName:   svc.clientName,
751                         Scopes:       svc.clientScope,
752                         Website:      svc.clientWebsite,
753                         RedirectURIs: svc.clientWebsite + "/oauth_callback",
754                 })
755                 if err != nil {
756                         return "", "", err
757                 }
758
759                 app = model.App{
760                         InstanceDomain: instance,
761                         InstanceURL:    instanceURL,
762                         ClientID:       mastoApp.ClientID,
763                         ClientSecret:   mastoApp.ClientSecret,
764                 }
765
766                 err = svc.appRepo.Add(app)
767                 if err != nil {
768                         return "", "", err
769                 }
770         }
771
772         u, err := url.Parse("/oauth/authorize")
773         if err != nil {
774                 return
775         }
776
777         q := make(url.Values)
778         q.Set("scope", "read write follow")
779         q.Set("client_id", app.ClientID)
780         q.Set("response_type", "code")
781         q.Set("redirect_uri", svc.clientWebsite+"/oauth_callback")
782         u.RawQuery = q.Encode()
783
784         redirectUrl = instanceURL + u.String()
785
786         return
787 }
788
789 func (svc *service) Signin(c *model.Client, sessionID string,
790         code string) (token string, userID string, err error) {
791
792         if len(code) < 1 {
793                 err = errInvalidArgument
794                 return
795         }
796
797         err = c.AuthenticateToken(ctx, code, svc.clientWebsite+"/oauth_callback")
798         if err != nil {
799                 return
800         }
801         token = c.GetAccessToken(ctx)
802
803         u, err := c.GetAccountCurrentUser(ctx)
804         if err != nil {
805                 return
806         }
807         userID = u.ID
808
809         return
810 }
811
812 func (svc *service) Signout(c *model.Client) (err error) {
813         svc.sessionRepo.Remove(c.Session.ID)
814         return
815 }
816
817 func (svc *service) Post(c *model.Client, content string,
818         replyToID string, format string, visibility string, isNSFW bool,
819         files []*multipart.FileHeader) (id string, err error) {
820
821         var mediaIDs []string
822         for _, f := range files {
823                 a, err := c.UploadMediaFromMultipartFileHeader(ctx, f)
824                 if err != nil {
825                         return "", err
826                 }
827                 mediaIDs = append(mediaIDs, a.ID)
828         }
829
830         tweet := &mastodon.Toot{
831                 Status:      content,
832                 InReplyToID: replyToID,
833                 MediaIDs:    mediaIDs,
834                 ContentType: format,
835                 Visibility:  visibility,
836                 Sensitive:   isNSFW,
837         }
838
839         s, err := c.PostStatus(ctx, tweet)
840         if err != nil {
841                 return
842         }
843
844         return s.ID, nil
845 }
846
847 func (svc *service) Like(c *model.Client, id string) (count int64, err error) {
848         s, err := c.Favourite(ctx, id)
849         if err != nil {
850                 return
851         }
852         count = s.FavouritesCount
853         return
854 }
855
856 func (svc *service) UnLike(c *model.Client, id string) (count int64, err error) {
857         s, err := c.Unfavourite(ctx, id)
858         if err != nil {
859                 return
860         }
861         count = s.FavouritesCount
862         return
863 }
864
865 func (svc *service) Retweet(c *model.Client, id string) (count int64, err error) {
866         s, err := c.Reblog(ctx, id)
867         if err != nil {
868                 return
869         }
870         if s.Reblog != nil {
871                 count = s.Reblog.ReblogsCount
872         }
873         return
874 }
875
876 func (svc *service) UnRetweet(c *model.Client, id string) (
877         count int64, err error) {
878         s, err := c.Unreblog(ctx, id)
879         if err != nil {
880                 return
881         }
882         count = s.ReblogsCount
883         return
884 }
885
886 func (svc *service) Vote(c *model.Client, id string, choices []string) (err error) {
887         _, err = c.Vote(ctx, id, choices)
888         if err != nil {
889                 return
890         }
891         return
892 }
893
894 func (svc *service) Follow(c *model.Client, id string, reblogs *bool) (err error) {
895         _, err = c.AccountFollow(ctx, id, reblogs)
896         return
897 }
898
899 func (svc *service) UnFollow(c *model.Client, id string) (err error) {
900         _, err = c.AccountUnfollow(ctx, id)
901         return
902 }
903
904 func (svc *service) Mute(c *model.Client, id string) (err error) {
905         _, err = c.AccountMute(ctx, id)
906         return
907 }
908
909 func (svc *service) UnMute(c *model.Client, id string) (err error) {
910         _, err = c.AccountUnmute(ctx, id)
911         return
912 }
913
914 func (svc *service) Block(c *model.Client, id string) (err error) {
915         _, err = c.AccountBlock(ctx, id)
916         return
917 }
918
919 func (svc *service) UnBlock(c *model.Client, id string) (err error) {
920         _, err = c.AccountUnblock(ctx, id)
921         return
922 }
923
924 func (svc *service) Subscribe(c *model.Client, id string) (err error) {
925         _, err = c.Subscribe(ctx, id)
926         return
927 }
928
929 func (svc *service) UnSubscribe(c *model.Client, id string) (err error) {
930         _, err = c.UnSubscribe(ctx, id)
931         return
932 }
933
934 func (svc *service) SaveSettings(c *model.Client, s *model.Settings) (err error) {
935         switch s.NotificationInterval {
936         case 0, 30, 60, 120, 300, 600:
937         default:
938                 return errInvalidArgument
939         }
940         session, err := svc.sessionRepo.Get(c.Session.ID)
941         if err != nil {
942                 return
943         }
944
945         session.Settings = *s
946         return svc.sessionRepo.Add(session)
947 }
948
949 func (svc *service) MuteConversation(c *model.Client, id string) (err error) {
950         _, err = c.MuteConversation(ctx, id)
951         return
952 }
953
954 func (svc *service) UnMuteConversation(c *model.Client, id string) (err error) {
955         _, err = c.UnmuteConversation(ctx, id)
956         return
957 }
958
959 func (svc *service) Delete(c *model.Client, id string) (err error) {
960         return c.DeleteStatus(ctx, id)
961 }
962
963 func (svc *service) ReadNotifications(c *model.Client, maxID string) (err error) {
964         return c.ReadNotifications(ctx, maxID)
965 }
966
967 func (svc *service) Bookmark(c *model.Client, id string) (err error) {
968         _, err = c.Bookmark(ctx, id)
969         return
970 }
971
972 func (svc *service) UnBookmark(c *model.Client, id string) (err error) {
973         _, err = c.Unbookmark(ctx, id)
974         return
975 }