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